# 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.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()