Files
gplan-2/game/tests/unit/presentation/level_map_info_fog_test.gd
T

228 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# tests/unit/presentation/level_map_info_fog_test.gd — 信息迷雾呈现显隐直接单测
# 断言面 = LevelMapScreen.node_display 决策面(显隐/Boss 豁免/详情零显示/label 三态)+
# display_probe 真机绘制面读数+大图(11 层×6 槽含双 Boss)画布拟合与布局包络。
# 节点图夹具白盒注入 LevelRunner(level_row+停点态),屏体真机实例化走 open_choices 真实入口。
# 运行:godot --headless --path game --script res://tests/unit/presentation/level_map_info_fog_test.gd(expect 0)
extends SceneTree
const Kit := preload("res://tests/unit/test_kit.gd")
const LevelTableScript := preload("res://data/schemas/level_table.gd")
const ScreenScript := preload("res://presentation/views/level_map_screen.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var db = load("res://core/config_db.gd").new()
var loaded: bool = db.load_tables("res://data/tables", false)
Kit.check_quiet(loaded, "迷雾呈现用例:数值表加载成功")
if not loaded:
quit(Kit.finish("level_map_info_fog_test"))
return
_test_fog_hides_type()
_test_boss_exempt_and_multi_boss()
_test_details_never_shown()
_test_label_three_states()
await _test_big_graph_fit(db)
await _test_layout_cache(db)
quit(Kit.finish("level_map_info_fog_test"))
# 白盒 runner:夹具行集注入 db.level + level_row,入口停点态(node_index=-1,候选=首层首节点句柄 1)
func _runner(db, nodes: Array, candidates: Array, n: int = 2):
var t := LevelTableScript.new()
t.info_visible_layers = n
t.rows = [{"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes}]
db.run.level = t
var r = load("res://core/level_runner.gd").new(db, load("res://core/rng_service.gd").new())
r.level_row.assign({"level_index": 1, "display_name": "夹具关", "rest_heal_pct": 30, "nodes": nodes})
r.node_index = -1
r._candidates.assign(candidates)
return r
# ---- ① 范围外只见结构:雾下节点类型图标与文字均不绘制〔fv2〕----
func _test_fog_hides_type() -> void:
var node := {"id": 3, "type": "battle", "depth": 3, "next": []}
var shown: Dictionary = ScreenScript.node_display(node, 2, [2], false, false)
Kit.check("可见集内节点:图标/类型文字均绘制〔fv2〕",
bool(shown["draw_icon"]) and bool(shown["draw_label"]))
var fogged: Dictionary = ScreenScript.node_display(node, 3, [2], false, false)
Kit.check("雾下节点:图标与类型文字均不绘制(显隐语义,非灰化)〔fv2〕",
not bool(fogged["draw_icon"]) and not bool(fogged["draw_label"]))
Kit.check("雾下非 Boss 节点无 Boss 描边〔fv2〕", not bool(fogged["boss_border"]))
# ---- ② Boss/终点不入雾+多终点标记〔fv3〕----
func _test_boss_exempt_and_multi_boss() -> void:
var type_vis: Array = [1, 7, 9] # 双 Boss 图:7/9 均在可见集
var boss_a: Dictionary = ScreenScript.node_display({"id": 7, "type": "boss", "depth": 7, "next": []}, 7, type_vis, false, false)
var boss_b: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, type_vis, false, false)
Kit.check("多终点:两个 Boss 节点均带 Boss 描边〔fv3〕",
bool(boss_a["boss_border"]) and bool(boss_b["boss_border"]))
Kit.check("Boss 不入雾:可见集与图标/文字三通道均在〔fv3〕",
bool(boss_a["draw_icon"]) and bool(boss_a["draw_label"]) and bool(boss_b["draw_icon"]))
var far_boss: Dictionary = ScreenScript.node_display({"id": 9, "type": "boss", "depth": 9, "next": []}, 9, [1], false, false)
Kit.check("Boss 豁免独立于可见集:不在 type_vis 的 Boss 仍出示类型〔fv3〕",
bool(far_boss["draw_icon"]) and bool(far_boss["draw_label"]))
var fogged_battle: Dictionary = ScreenScript.node_display({"id": 5, "type": "battle", "depth": 5, "next": []}, 5, type_vis, false, false)
Kit.check("Boss 豁免不扩散:雾下非 Boss 仍隐类型〔fv3〕", not bool(fogged_battle["draw_icon"]))
# ---- ③ 节点内详情图上从不显示〔fv5〕----
func _test_details_never_shown() -> void:
var plain := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
var loaded := {"id": 3, "type": "battle", "depth": 3, "next": [4], "enemy_id": "goblin_secret", "reward_id": "reward_secret"}
var a: Dictionary = ScreenScript.node_display(plain, 3, [3], false, true)
var b: Dictionary = ScreenScript.node_display(loaded, 3, [3], false, true)
var same := true
for k in a.keys():
if a[k] != b[k]:
same = false
Kit.check("决策面不消费内容键:携带 enemy_id/reward_id 与不带者输出逐键相等〔fv5〕", same)
Kit.check("label_text 不含内容值(实际「%s」)〔fv5〕" % String(b["label_text"]),
not String(b["label_text"]).contains("goblin_secret") and not String(b["label_text"]).contains("reward_secret"))
# ---- ④ label_text 三态文案〔fv2〕----
func _test_label_three_states() -> void:
var node := {"id": 3, "type": "battle", "depth": 3, "next": [4]}
Kit.check("当前位文案=「当前位置」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], true, false)["label_text"]) == "当前位置")
Kit.check("候选文案=「战斗 · 可选」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], false, true)["label_text"]) == "战斗 · 可选")
Kit.check("其余文案=类型名「战斗」〔fv2〕",
String(ScreenScript.node_display(node, 3, [3], false, false)["label_text"]) == "战斗")
# ---- 大图(11 层×6 槽含双 Boss)画布拟合与布局包络〔fv8〕----
# 大图夹具:按 12 层×6 槽构造(id = 图内句柄 = 构造序号;depth 随构造序号严格前进——生成图连线铁律),
# 层 0 入口、末层双 Boss(id 61/62,句柄互异、next 恒空);含层 1 首节点→第 3 层节点的跨层长跳边。
# 跳边目标按 BFS 最短路径提前一层,其后继链随之逐层提前(目标并入第 2 层槽组)——布局落 11 层。
# 断言按布局实测取最大层/最大槽,画布公式与之同源。
func _big_graph_nodes() -> Array:
var layers: Array = []
var total := 0
var tiers := 12
for l in tiers:
var width: int = 1 if l == 0 else (2 if l == tiers - 1 else 6)
var ids: Array = []
for i in width:
ids.append(total)
total += 1
layers.append(ids)
var nodes: Array = []
for l in tiers:
for i in layers[l].size():
var nid: int = layers[l][i]
var t := "battle"
var next_ids: Array = []
if l == tiers - 1:
t = "boss"
elif l == 0:
next_ids = layers[1].duplicate() # 入口扇出到全层(层宽 6)
else:
if i % 5 == 0:
t = "reward" if l % 2 == 0 else "rest"
var w2: int = layers[l + 1].size()
next_ids = [layers[l + 1][i % w2], layers[l + 1][(i + 1) % w2]]
nodes.append({"id": nid, "type": t, "depth": nid, "next": next_ids})
var jump_from: int = layers[1][0]
var jump_to: int = layers[3][2]
for n in nodes:
if int(n["id"]) == jump_from:
(n["next"] as Array).append(jump_to)
break
return nodes
func _test_big_graph_fit(db) -> void:
var nodes := _big_graph_nodes()
var entry: Dictionary = nodes[0]
var runner = _runner(db, nodes, [nodes[1]])
var screen = ScreenScript.new()
root.add_child(screen)
screen.open_choices([nodes[1]], runner)
await process_frame
await process_frame
var min_size: Vector2 = screen.canvas_probe()["min_size"]
var pos: Dictionary = screen.layout_probe(nodes)
var origin: Vector2 = (pos[entry] as Vector2) - Vector2(ScreenScript.LAYER_BASE_X, ScreenScript.SLOT_BASE_Y)
# 最大层 / 最大槽按布局实测反解(公式与 _fit_canvas 同源)
var maxl := 0
var maxslot := 0
for node in nodes:
var p: Vector2 = pos[node]
maxl = maxi(maxl, int(round((p.x - origin.x - ScreenScript.LAYER_BASE_X) / ScreenScript.LAYER_STEP_X)))
maxslot = maxi(maxslot, int(round((p.y - origin.y - ScreenScript.SLOT_BASE_Y) / ScreenScript.SLOT_STEP_Y)))
Kit.check("大图夹具规模:≥11 层 × ≥6 槽(实测最大层 %d/最大槽 %d)〔fv8〕" % [maxl, maxslot],
maxl >= 10 and maxslot >= 5)
var need_w: float = ScreenScript.LAYER_BASE_X + maxl * ScreenScript.LAYER_STEP_X \
+ ScreenScript.NODE_HALF.x + 8.0
var need_h: float = ScreenScript.SLOT_BASE_Y + maxslot * ScreenScript.SLOT_STEP_Y \
+ ScreenScript.NODE_HALF.y + 24.0
Kit.check("大图拟合:画布 min.x 覆盖最深节点(%.1f ≥ %.1f)〔fv8〕" % [min_size.x, need_w],
min_size.x >= need_w - 0.01)
Kit.check("大图拟合:画布 min.y 覆盖最宽层底节点(%.1f ≥ %.1f)〔fv8〕" % [min_size.y, need_h],
min_size.y >= need_h - 0.01)
var fit_rect := Rect2(origin, min_size)
var all_in := true
for node in nodes:
var p: Vector2 = pos[node]
if not fit_rect.encloses(Rect2(p - ScreenScript.NODE_HALF, ScreenScript.NODE_HALF * 2.0)):
all_in = false
Kit.check("大图布局:全节点方块落在拟合画布内(origin=(%.1f,%.1f) size=(%.1f,%.1f))〔fv8〕"
% [origin.x, origin.y, min_size.x, min_size.y], all_in)
var disp: Dictionary = screen.display_probe()
var boss_ids: Array = []
for node in nodes:
if String(node["type"]) == "boss":
boss_ids.append(int(node["id"]))
Kit.check("大图呈现:两个终点 Boss 均有描边标记〔fv8〕",
disp.has(boss_ids[0]) and disp.has(boss_ids[1])
and bool(disp[boss_ids[0]]["boss_border"]) and bool(disp[boss_ids[1]]["boss_border"]))
Kit.check("大图呈现:入口停点远处战斗节点(句柄 32,层 6)类型入雾〔fv8〕", not bool(disp[32]["draw_icon"]))
screen.queue_free()
# ---- 布局缓存命中与换代失效〔levelmap批 V1/V2〕----
# 真实入口 = open_choices 后的真实绘制路径(_draw_graph→_layout,与 hover→_candidate_at 链同一缓存层);
# 命中读数 = is_same 同实例判别(重算必产新字典、命中返回缓存原实例)——零生产计数器。
func _test_layout_cache(db) -> void:
var nodes := _big_graph_nodes()
var runner = _runner(db, nodes, [nodes[1]])
var screen = ScreenScript.new()
root.add_child(screen)
screen.open_choices([nodes[1]], runner)
await process_frame
await process_frame
var p1: Dictionary = screen.layout_probe(nodes)
var p2: Dictionary = screen.layout_probe(nodes)
Kit.check("布局缓存:同 nodes 两次查询返回同一实例(第二次命中缓存)〔levelmap批 V1〕",
is_same(p1, p2))
# 换代失效:节点集内容变 → 缓存键(内容等价)击穿 → 重算换写。
# 判别式 = 同一性:若缓存未随换代失效,新图查询将命中旧缓存返回同实例;重算必产新字典。
# (不回查旧图——换代后 _content_size 已随 open_choices 重拟合到新图,旧图混查属生产外用法。)
var chain: Array = [
{"id": 0, "type": "battle", "depth": 0, "next": [1]},
{"id": 1, "type": "boss", "depth": 1, "next": []},
]
runner.level_row["nodes"] = chain
screen.open_choices([chain[0]], runner)
await process_frame
await process_frame # 换代图真实绘制路径重算(缓存随内容变失效重写)
var pc1: Dictionary = screen.layout_probe(chain)
var pc2: Dictionary = screen.layout_probe(chain)
Kit.check("布局缓存换代:新图查询返回新实例非旧缓存(失效重算生效)〔levelmap批 V2〕",
not is_same(p1, pc1) and is_same(pc1, pc2)
and (pc1[chain[0]] as Vector2) != (p1[nodes[0]] as Vector2))
screen.queue_free()