231 lines
12 KiB
GDScript
231 lines
12 KiB
GDScript
# 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)
|
||
# 第三判据原=新旧图 id0 位置差——改前靠容器延迟收敛的 origin 中间态偶然成立(换图首探针
|
||
# 画布未及重排,origin≠0);视口化批画布同帧收敛(canvas.size 恒=内容包络)后两图 origin
|
||
# 恒等、id0 同层同槽位置恒等,前提失义。等强换键=重算字典键数恰为新图节点数(旧缓存
|
||
# 63 键,换代不重算必不成立),同一性判据(核心失效重算面)不动。
|
||
Kit.check("布局缓存换代:新图查询返回新实例非旧缓存(失效重算生效)〔levelmap批 V2〕",
|
||
not is_same(p1, pc1) and is_same(pc1, pc2) and pc1.size() == chain.size())
|
||
screen.queue_free()
|