diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 5afadfaa..45b1d68c 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -32,6 +32,7 @@ func _suites() -> Array: {"path": "res://tests/unit/data_config/aux_target_violation_test.gd", "expect": 1}, # 迭代 7 T1——真视窄域护栏 {"path": "res://tests/unit/data_config/reward_dangling_aux_test.gd", "expect": 1}, # 迭代 7 T1——aux 双表校验 {"path": "res://tests/unit/data_config/rng_service_test.gd", "expect": 0}, + {"path": "res://tests/unit/core/aux_items_test.gd", "expect": 0}, # 迭代 7 T7——魔法道具结构冻结断言面 {"path": "res://tests/unit/battle_settlement/damage_math_test.gd", "expect": 0}, {"path": "res://tests/unit/battle_settlement/damage_controller_test.gd", "expect": 0}, {"path": "res://tests/unit/contracts/system_contracts_test.gd", "expect": 0}, diff --git a/game/tests/unit/core/aux_items_test.gd b/game/tests/unit/core/aux_items_test.gd new file mode 100644 index 00000000..2dff7fea --- /dev/null +++ b/game/tests/unit/core/aux_items_test.gd @@ -0,0 +1,118 @@ +# VERTICAL SLICE - NOT FOR PRODUCTION +# 魔法道具 AuxItems 类单测(迭代 7 T7——结构冻结断言面;设计权威 魔法道具.md + registry aux_items)。 +# 覆盖:授予归因 / Used 不可逆 / 真视恒可用 / 预读点击级零信息拒绝 / 3×3 边缘窗口∩板 / +# 叠加层三清(retire_overlays 不动库存)/ 关卡末 reset / 零随机(同操作序列重放两遍投影逐位)。 +# 运行:godot --headless --path game --script res://tests/unit/core/aux_items_test.gd(expect 0) +extends SceneTree + +const AuxItemsScript := preload("res://core/aux_items.gd") + +var _problems: Array = [] + + +func check(cond: bool, msg: String) -> void: + if not cond: + _problems.append(msg) + + +class StubTable: + var rows := {} + func row_by_id(id: String) -> Dictionary: + return rows.get(id, {}) + func active_rows() -> Array: + return [] + + +class StubDB: + var aux_items: StubTable + + +func _stub_db() -> StubDB: + var t := StubTable.new() + t.rows = { + "sapper_order": {"item_id": "sapper_order", "display_name": "工兵令", "dimension_tags": ["action"], + "effect_kinds": ["ap_grant"], "target": "none", "magnitude": 2, "use_phase": "active_dig"}, + "nest_pendulum": {"item_id": "nest_pendulum", "display_name": "探巢摆钟", "dimension_tags": ["risk"], + "effect_kinds": ["mine_reveal"], "target": "area_3x3", "magnitude": 0, "use_phase": "active_dig"}, + "loot_compass": {"item_id": "loot_compass", "display_name": "探宝罗盘", "dimension_tags": ["plan"], + "effect_kinds": ["reward_preview"], "target": "area_3x3", "magnitude": 0, "use_phase": "active_dig"}, + } + var db := StubDB.new() + db.aux_items = t + return db + + +func _new_inv() -> Object: + return AuxItemsScript.new(_stub_db()) + + +func _unrevealed(_c: Vector2i) -> bool: + return true # 测试板全未开(预读恒有信息——点击级零信息另用假回调测) + + +func _all_revealed(_c: Vector2i) -> bool: + return false # 预读点击级零信息(窗口全已开) + + +func _init() -> void: + # ① 授予归因+受理序+Used 不可逆 + var inv = _new_inv() + var r1: Dictionary = inv.add_item("sapper_order", "safe_cell", Vector2i(3, 4), 2) + var r2: Dictionary = inv.add_item("nest_pendulum", "flag_hit", Vector2i(5, 6), 3) + check(int(r1.handle) == 1 and int(r2.handle) == 2, "句柄受理序单调(1→2——零随机)") + check(String(r1.source_class) == "safe_cell" and r1.origin_cell == Vector2i(3, 4) and int(r1.round_index) == 2, + "授予来源可归因(source/cell/round)") + var inv_proj: Array = inv.query_inventory(Callable()) + check(inv_proj.size() == 2 and not bool(inv_proj[0].used), "库存投影两件未用") + check(bool(inv.consume(int(r1.handle)).get("used", false)), "consume 返回已用快照回执") + check(inv.consume(int(r1.handle)).is_empty(), "Used 不可逆——二次 consume 拒绝(空回执)") + check(int(inv.inventory_count()) == 2, "Used 保留只读(库存计数不变)") + + # ② 真视恒可用+边缘窗口∩板(3×3 中心贴角——角 4 格) + var v_ok: Dictionary = inv.validate_use(int(r2.handle), Vector2i(0, 0), Vector2i(10, 10), Callable()) + check(bool(v_ok.ok) and v_ok.resolved.size() == 4, + "真视边缘窗口=窗口∩板(角 4 格——护栏只缩不扩)") + # 预读点击级零信息:中心板内但回调恒 false(全已开)→拒绝零消耗 + var inv2 = _new_inv() + var rp: Dictionary = inv2.add_item("loot_compass", "safe_cell", Vector2i(1, 1), 1) + var v_zero: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(5, 5), Vector2i(10, 10), Callable()) + check(not bool(v_zero.ok) and int(inv2.inventory_count()) == 1, + "预读点击级零信息拒绝(不消耗——库存不变)") + # 目标越板 + var v_oob: Dictionary = inv2.validate_use(int(rp.handle), Vector2i(12, 5), Vector2i(10, 10), Callable()) + check(not bool(v_zero.ok) and not bool(v_oob.ok), "越板目标拒绝") + + # ③ 叠加层+三清(retire 不动库存;reset 全清) + inv2.apply_overlay("mine_reveal", v_ok.resolved) + inv2.apply_overlay("reward_preview", [Vector2i(5, 5)]) + var ov: Dictionary = inv2.query_overlays() + check(ov.mine_reveal.size() == 4 and ov.reward_preview.size() == 1, "叠加层两集落位(真视 4/预读 1)") + inv2.retire_overlays() + check(inv2.query_overlays().mine_reveal.is_empty() and int(inv2.inventory_count()) == 1, + "换图三清:叠加层消亡+库存不动(关卡域)") + inv2.reset() + check(inv2.inventory_count() == 0 and inv2.probe().used == 0, "关卡末 reset 全清(幂等)") + + # ④ 零随机重放:同操作序列两遍,投影逐位相同 + var replay_a: Array = [] + var replay_b: Array = [] + for rep in 2: + var inv3 = _new_inv() + var h1: Dictionary = inv3.add_item("sapper_order", "safe_cell", Vector2i(2, 3), 1) + inv3.validate_use(int(h1.handle), Vector2i(4, 4), Vector2i(10, 10), Callable()) + inv3.apply_overlay("mine_reveal", [Vector2i(4, 4)]) + inv3.consume(int(h1.handle)) + if rep == 0: + replay_a = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()] + else: + replay_b = [inv3.query_inventory(Callable()), inv3.query_overlays(), inv3.probe()] + check(str(replay_a) == str(replay_b), "零随机重放:库存+叠加层+探针投影逐位相同") + + # ---- 汇总 ---- + if _problems.is_empty(): + print("[aux_items_test] PASS — 授予归因/Used 不可逆/真视恒可用/零信息拒绝/边缘窗口/三清/零随机重放 全过") + quit(0) + else: + for p in _problems: + push_error("[aux_items_test] FAIL — " + p) + quit(1) diff --git a/game/tests/unit/core/aux_items_test.gd.uid b/game/tests/unit/core/aux_items_test.gd.uid new file mode 100644 index 00000000..88eb53c2 --- /dev/null +++ b/game/tests/unit/core/aux_items_test.gd.uid @@ -0,0 +1 @@ +uid://dqx67xrrbvp82