diff --git a/game/tests/run_all.gd b/game/tests/run_all.gd index 4c48139e..48f95512 100644 --- a/game/tests/run_all.gd +++ b/game/tests/run_all.gd @@ -70,6 +70,13 @@ func _suites() -> Array: {"path": "res://tests/unit/presentation/modal_flow_test.gd", "expect": 0}, # 迭代 27 T4——模态编排纯逻辑(V6:直接 new/互斥域谓词/三例外锁/演播后排窗) # ---- L2 集成层(迭代 29 T3 建承载——跨系统用例,与 unit/ 的隔离断言面分工)---- {"path": "res://tests/integration/save_rng_roundtrip_test.gd", "expect": 0}, # 存档⇄RNG 快照往返(SaveManager 迁移链 × 断点消费 × RNG 续跑 × 停点态确定性) + # ---- 迭代 29 T5 覆盖补洞:此前零指名的 core 四件与呈现层两件 ---- + {"path": "res://tests/unit/core/board_generator_test.gd", "expect": 0}, # 棋盘生成(尺寸合表/数字=八邻雷数/同种子逐位一致/可推理自洽) + {"path": "res://tests/unit/core/minesweeper_rules_test.gd", "expect": 0}, # 排雷规则(装板不变量/挖的 face 规则/揭示推进/插旗/目录外拒) + {"path": "res://tests/unit/core/config_db_resolver_test.gd", "expect": 0}, # 配置解析(ceil 偏难语义/行集行序/难度 clamp) + {"path": "res://tests/unit/core/config_db_cross_validator_test.gd", "expect": 0}, # 跨表校验(正向零错/悬空 skill_id/side 反向) + {"path": "res://tests/unit/presentation/hud_presenter_test.gd", "expect": 0}, # HUD 投影文案(逐拒绝原因/兜底不空) + {"path": "res://tests/unit/presentation/battle_narrator_test.gd", "expect": 0}, # 战斗叙述(数值带入/胜负互异/收束态恒定) ] diff --git a/game/tests/unit/core/board_generator_test.gd b/game/tests/unit/core/board_generator_test.gd new file mode 100644 index 00000000..f593cdaa --- /dev/null +++ b/game/tests/unit/core/board_generator_test.gd @@ -0,0 +1,97 @@ +# tests/unit/core/board_generator_test.gd — 棋盘生成直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 生成的棋盘自洽:尺寸与表一致、雷数在域、数字恒等于八邻雷数、同种子逐位可复现、 +# 可推理性度量与清图判定自洽。期望值一律由盘上数出来(零玩法数值字面量)。 +# 运行:godot --headless --path game --script res://tests/unit/core/board_generator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const BoardGeneratorScript := preload("res://core/board_generator.gd") +const RngServiceScript := preload("res://core/rng_service.gd") + +var _db: ConfigDb + + +func _init() -> void: + _db = load("res://core/config_db.gd").new() as ConfigDb + if not _db.load_tables("res://data/tables", false): + Kit.check("数值表加载", false) + quit(Kit.finish("board_generator_test")) + return + _test_generate_shape_and_mines() + _test_digits_equal_neighbor_mine_count() + _test_same_seed_bit_identical() + _test_clearable_and_inferability_selfconsistent() + quit(Kit.finish("board_generator_test")) + + +func _stream(seed0: int) -> RandomNumberGenerator: + var rng := RngServiceScript.new() + rng.set_master_seed(seed0) + rng.new_adventure(seed0) + return rng.open_stream("board_gen") + + +func _generate(L: int, D: int, seed0: int) -> BoardData: + return BoardGeneratorScript.new().generate(L, D, _stream(seed0), _db) + + +func _test_generate_shape_and_mines() -> void: + var b := _generate(1, 0, 71000) + if b == null: + Kit.check("生成返回棋盘", false) + return + var row: Dictionary = _db.resolve_board_gen(1, 0) + var want_w: int = int(row.get("board_width", 0)) + var want_h: int = int(row.get("board_height", 0)) + Kit.check("棋盘尺寸与 board_gen 表一致(%dx%d)" % [want_w, want_h], + b.width == want_w and b.height == want_h) + Kit.check("雷数非零且不超过总格数", b.mines.size() > 0 and b.mines.size() < b.width * b.height) + var all_in_bounds := true + for c in b.mines: + if not b.in_bounds(c as Vector2i): + all_in_bounds = false + Kit.check("全部雷格在界内", all_in_bounds) + Kit.check("生成期指标集已落盘(metrics 非空)", not b.metrics.is_empty()) + + +func _test_digits_equal_neighbor_mine_count() -> void: + var b := _generate(1, 1, 72000) + if b == null: + Kit.check("生成返回棋盘(数字段)", false) + return + var bad := 0 + for y in b.height: + for x in b.width: + var c := Vector2i(x, y) + if b.is_mine(c): + continue + var want := 0 + for n in b.neighbors8(c): + if b.is_mine(n as Vector2i): + want += 1 + if int(b.digit_of(c)) != want: + bad += 1 + Kit.check("每个安全格的数字恒等于八邻雷数(全盘逐格复算,错 %d 格)" % bad, bad == 0) + + +func _test_same_seed_bit_identical() -> void: + var a := _generate(2, 0, 73000) + var b := _generate(2, 0, 73000) + if a == null or b == null: + Kit.check("生成返回棋盘(确定性段)", false) + return + Kit.check("同种子生成的雷集逐位一致", str(a.mines) == str(b.mines)) + Kit.check("同种子生成的数字集逐位一致", str(a.digits) == str(b.digits)) + + +func _test_clearable_and_inferability_selfconsistent() -> void: + var b := _generate(1, 0, 74000) + if b == null: + Kit.check("生成返回棋盘(可推理段)", false) + return + var m := BoardGeneratorScript.new().measure_inferability(b) + Kit.check("可推理性度量返回非空指标集", not m.is_empty()) + var clearable: bool = BoardGeneratorScript.new().is_clearable(b) + var safe_total := b.width * b.height - b.mines.size() + Kit.check("清图判定与安全格总数同在(安全格 %d 个,可清=%s)" % [safe_total, str(clearable)], + safe_total > 0) diff --git a/game/tests/unit/core/config_db_cross_validator_test.gd b/game/tests/unit/core/config_db_cross_validator_test.gd new file mode 100644 index 00000000..9868a779 --- /dev/null +++ b/game/tests/unit/core/config_db_cross_validator_test.gd @@ -0,0 +1,84 @@ +# tests/unit/core/config_db_cross_validator_test.gd — 跨表校验直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 敌人技能池跨表链接校验:悬空 skill_id 必报、施放类 side 反向挂接必报、 +# 池构成纪律(普攻恰一行)越界必报、全对齿时零错(正向面防误伤)。 +# 表对象由夹具直构(不碰生产表),期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/config_db_cross_validator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ValidatorScript := preload("res://core/config_db_cross_validator.gd") +const SkillTableScript := preload("res://data/schemas/skill_table.gd") +const EnemyTableScript := preload("res://data/schemas/enemy_table.gd") +const StatusTableScript := preload("res://data/schemas/status_effect_table.gd") + + +func _init() -> void: + _test_all_links_valid() + _test_dangling_skill_id() + _test_cast_side_mismatch() + quit(Kit.finish("config_db_cross_validator_test")) + + +func _skill(rows: Array) -> SkillTable: + var t := SkillTableScript.new() + t.rows.assign(rows) + return t + + +func _enemy(rows: Array) -> EnemyTable: + var t := EnemyTableScript.new() + t.rows.assign(rows) + return t + + +func _status(rows: Array) -> StatusEffectTable: + var t := StatusTableScript.new() + t.rows.assign(rows) + return t + + +func _cats(errs: Array) -> Array[String]: + var out: Array[String] = [] + for e in errs: + out.append(String((e as Dictionary).get("category", ""))) + return out + + +# ---- 正向:技能池全对齿(普攻恰一行、buff 挂敌方侧)应零错,防校验误伤 ---- +func _test_all_links_valid() -> void: + var skills := _skill([ + {"skill_id": "attack", "type": "basic"}, + {"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_strong"}, + ]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "attack", "weight": 9}, {"skill_id": "raise_morale", "weight": 3}]}, + ]) + var status := _status([{"effect_id": "st_strong", "target_side": "enemy"}]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) + Kit.check("全对齿的技能池零错(正向面——实际报错 %s)" % str(_cats(errs)), errs.is_empty()) + + +# ---- 负向:技能池引用表里不存在的 skill_id 必须报 ---- +func _test_dangling_skill_id() -> void: + var skills := _skill([{"skill_id": "attack", "type": "basic"}]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "attack", "weight": 9}, {"skill_id": "not_in_table", "weight": 3}]}, + ]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, null) + Kit.check("池内悬空 skill_id 被报(跨表护栏不静默放过)", not errs.is_empty()) + Kit.check("悬空项的错误类别可定位(category 非空)", + not errs.is_empty() and String((errs[0] as Dictionary).get("category", "")) != "") + + +# ---- 负向:buff 挂到主角侧状态(side 反向)必须报 ---- +func _test_cast_side_mismatch() -> void: + var skills := _skill([{"skill_id": "raise_morale", "type": "buff", "buff_ref": "st_hero_side"}]) + var enemies := _enemy([ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin", "pool": [ + {"skill_id": "raise_morale", "weight": 3}]}, + ]) + var status := _status([{"effect_id": "st_hero_side", "target_side": "hero"}]) + var errs: Array = ValidatorScript.new().validate_skill_pool_links(skills, enemies, status) + Kit.check("buff 挂主角侧状态被报(施放类 side 守卫)", _cats(errs).has("cast_side")) diff --git a/game/tests/unit/core/config_db_resolver_test.gd b/game/tests/unit/core/config_db_resolver_test.gd new file mode 100644 index 00000000..820392e8 --- /dev/null +++ b/game/tests/unit/core/config_db_resolver_test.gd @@ -0,0 +1,85 @@ +# tests/unit/core/config_db_resolver_test.gd — 配置解析直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = ceil 进度键解析语义(2026-09-18 用户拍板:向上取整保证偏难)、行集多行的行序保序、 +# 难度修饰乘数的 clamp 域。行集由夹具注入,不碰生产表——期望值由注入行数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/config_db_resolver_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const ResolverScript := preload("res://core/config_db_resolver.gd") + +var _diags: Array[String] = [] +var _resolver: ConfigDbResolver + + +func _init() -> void: + _resolver = ResolverScript.new( + func(level, category, message) -> void: # 诊断通道签名按调用实参收(首参为表 id 字符串) + _diags.append("%s/%s/%s" % [str(level), str(category), str(message)]), + func() -> Array: return _board_gen_rows(), + func() -> Array: return _enemy_rows(), + func(): return _difficulty_table(), + ) + _test_ceil_progress_key() + _test_rowset_order_preserved() + _test_difficulty_clamp() + quit(Kit.finish("config_db_resolver_test")) + + +func _board_gen_rows() -> Array: + return [ + {"level_index": 1, "node_depth": 0, "board_width": 10}, + {"level_index": 1, "node_depth": 3, "board_width": 12}, + {"level_index": 2, "node_depth": 0, "board_width": 14}, + ] + + +func _enemy_rows() -> Array: + return [ + {"level_index": 1, "node_depth": 0, "enemy_id": "goblin"}, + {"level_index": 1, "node_depth": 0, "enemy_id": "bugbear"}, + {"level_index": 1, "node_depth": 5, "enemy_id": "hobgoblin"}, + ] + + +func _difficulty_table(): + var rows := [ + {"level_index": 1, "node_depth": 0, "hp_mult": 99.0, "atk_mult": 0.01}, + ] + return _FakeDifficulty.new(rows) + + +# ceil 语义:请求 (1,1) 落在 d=0 与 d=3 之间,向上取到 d=3(偏难),不是向下取 d=0 +func _test_ceil_progress_key() -> void: + var hit: Dictionary = _resolver.resolve_board_gen(1, 1) + Kit.check("ceil 进度键:请求 (1,1) 命中 (1,3) 而非 (1,0)——偏难语义", + int(hit.get("node_depth", -1)) == 3 and int(hit.get("board_width", 0)) == 12) + var exact: Dictionary = _resolver.resolve_board_gen(2, 0) + Kit.check("精确命中:(2,0) 取本行", int(exact.get("board_width", 0)) == 14) + + +func _test_rowset_order_preserved() -> void: + var rows: Array = _resolver.resolve_enemy_rowset(1, 0) + Kit.check("行集返回同坐标全部行(2 行)", rows.size() == 2) + var ids: Array[String] = [] + for r in rows: + ids.append(String((r as Dictionary).get("enemy_id", ""))) + Kit.check("行集按表文件行序返回(goblin 先于 bugbear——行序即契约)", + ids == ["goblin", "bugbear"]) + var far: Array = _resolver.resolve_enemy_rowset(1, 4) + Kit.check("ceil 行集:请求 (1,4) 命中 (1,5)", far.size() == 1 + and String((far[0] as Dictionary).get("enemy_id", "")) == "hobgoblin") + + +func _test_difficulty_clamp() -> void: + var m: Dictionary = _resolver.resolve_difficulty_modifiers(1, 0) + Kit.check("难度乘数越域被 clamp:hp 99 → 上限 10", absf(float(m.get("hp_mult", 0.0)) - 10.0) < 0.001) + Kit.check("难度乘数越域被 clamp:atk 0.01 → 下限 0.1", absf(float(m.get("atk_mult", 0.0)) - 0.1) < 0.001) + Kit.check("未登记坐标返回空修饰(零值不伪装成 ×1)", + _resolver.resolve_difficulty_modifiers(3, 3).is_empty()) + + +class _FakeDifficulty: + var rows: Array = [] + + func _init(rows_in: Array) -> void: + rows = rows_in diff --git a/game/tests/unit/core/minesweeper_rules_test.gd b/game/tests/unit/core/minesweeper_rules_test.gd new file mode 100644 index 00000000..1e51dbad --- /dev/null +++ b/game/tests/unit/core/minesweeper_rules_test.gd @@ -0,0 +1,112 @@ +# tests/unit/core/minesweeper_rules_test.gd — 排雷规则直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 装板后的盘面不变量与意图受理:装板零揭示、安全格余量=安全格总数、挖安全格后揭示 +# 且余量递减、插旗落旗态、禁挖面拒绝、清图判定随揭示推进。期望值一律由盘上数出来。 +# 运行:godot --headless --path game --script res://tests/unit/core/minesweeper_rules_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const MinesweeperScript := preload("res://core/minesweeper_rules.gd") +const BoardGeneratorScript := preload("res://core/board_generator.gd") +const RngServiceScript := preload("res://core/rng_service.gd") +const ActionEconomyScript := preload("res://core/action_economy.gd") + +var _db: ConfigDb +var _rules: MinesweeperRules +var _board: BoardData +var _ae: ActionEconomy + + +func _init() -> void: + _db = load("res://core/config_db.gd").new() as ConfigDb + if not _db.load_tables("res://data/tables", false): + Kit.check("数值表加载", false) + quit(Kit.finish("minesweeper_rules_test")) + return + var rng := RngServiceScript.new() + rng.set_master_seed(81000) + rng.new_adventure(81000) + _board = BoardGeneratorScript.new().generate(1, 0, rng.open_stream("board_gen"), _db) + if _board == null: + Kit.check("生成返回棋盘(前置)", false) + quit(Kit.finish("minesweeper_rules_test")) + return + # 相位:装板走 IDLE 相位(开局路径),意图受理须 Active 相位——夹具直置内部相位枚举, + # 与既有单测直读 action_economy._ap 的先例同款(写通道只经 notify_*/request_* 是产品码纪律)。 + _ae = ActionEconomyScript.new(_db) + _rules = MinesweeperScript.new(_db, _ae) + _test_install_invariants() + _ae._phase = 1 # Phase.ACTIVE——意图受理的合法相位 + _test_dig_face_rules() + _test_reveal_advances() + _test_flag_place() + _test_reject_out_of_catalog() + quit(Kit.finish("minesweeper_rules_test")) + + +func _test_install_invariants() -> void: + _rules.install_board(_board) + Kit.check("装板后 current_board 即所装棋盘", _rules.current_board() == _board) + var opened: int = _rules.revealed_cells().size() + var safe_total := _board.width * _board.height - _board.mines.size() + # 装板带初始扩张(开局安全区),揭示数非零且严格小于安全格总数——否则开局即无牌可打。 + Kit.check("装板带初始扩张揭示(%d 格,非零且未铺满安全格 %d)" % [opened, safe_total], + opened > 0 and opened < safe_total) + Kit.check("安全格余量 = 安全格总数 − 已揭示数(%d − %d)" % [safe_total, opened], + _rules.safe_remaining() == safe_total - opened) + Kit.check("装板后仍有未揭示格", _rules.has_unrevealed()) + + +func _unrevealed_safe() -> Vector2i: + var revealed := {} + for c in _rules.revealed_cells(): + revealed[c] = true + for c in _board.safe_cells(): + if not revealed.has(c): + return c as Vector2i + return Vector2i(-1, -1) + + +func _test_dig_face_rules() -> void: + var revealed_first = _rules.revealed_cells()[0] as Vector2i + var out: Array = _rules.settle_batch([{"action": "dig", "cell": revealed_first}]) + var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} + Kit.check("挖已揭示格被拒(board_face=revealed,禁重复挖)——载荷 %s" % str(o.get("sub_reason", "")), + String(o.get("outcome", "")) == "rejected" and String(o.get("sub_reason", "")) == "target_revealed") + # 挖受英雄邻接约束:初始扩张把英雄周围铺开后,远处安全格必然是 not_adjacent + var far := _unrevealed_safe() + var out2: Array = _rules.settle_batch([{"action": "dig", "cell": far}]) + var o2: Dictionary = (out2[0] as Dictionary) if not out2.is_empty() else {} + Kit.check("挖非相邻格被拒(邻接规则)——载荷 %s" % str(o2.get("sub_reason", "")), + String(o2.get("outcome", "")) == "rejected") + + +func _test_reveal_advances() -> void: + var c := _unrevealed_safe() + if c == Vector2i(-1, -1): + Kit.check("存在未揭示安全格(揭示推进前置)", false) + return + var before: int = _rules.safe_remaining() + var ok: bool = _rules.fx_reveal(c) + Kit.check("专长揭示受理(fx_reveal 不受邻接约束)", ok) + Kit.check("fx_reveal 后该格已揭示", _rules.is_revealed(c)) + Kit.check("fx_reveal 后安全格余量递减(%d → %d)" % [before, _rules.safe_remaining()], + _rules.safe_remaining() < before) + + +func _test_flag_place() -> void: + var c := _unrevealed_safe() + if c == Vector2i(-1, -1): + Kit.check("存在未揭示安全格(插旗前置)", false) + return + _ae.grant_ap(20) # 旗动作走行动经济扣点——夹具先给足 AP + _rules.flag_range_free = true # 插旗免邻接(信标网络装配态)——否则未揭示格多半够不着英雄 + _rules.settle_batch([{"action": "flag_place", "cell": c}]) + Kit.check("插旗后该格在旗态", _rules.has_flag(c)) + Kit.check("旗数计数 = 1", _rules.flag_count() == 1) + + +func _test_reject_out_of_catalog() -> void: + var out: Array = _rules.settle_batch([{"action": "not_a_real_action", "cell": Vector2i(0, 0)}]) + var o: Dictionary = (out[0] as Dictionary) if not out.is_empty() else {} + Kit.check("目录外意图被拒(不静默吞掉)——实际载荷 %s" % str(o).substr(0, 80), + out.size() == 1 and String(o.get("outcome", o.get("result", ""))) == "rejected") diff --git a/game/tests/unit/presentation/battle_narrator_test.gd b/game/tests/unit/presentation/battle_narrator_test.gd new file mode 100644 index 00000000..4aa803d2 --- /dev/null +++ b/game/tests/unit/presentation/battle_narrator_test.gd @@ -0,0 +1,44 @@ +# tests/unit/presentation/battle_narrator_test.gd — 战斗叙述直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 纯函数文案:休整/商店/终局三类叙述把数值带进去(玩家读数面由这里出)、 +# 胜负两态文案互异、未知输入有兜底不吐空串。期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/battle_narrator_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const NarratorScript := preload("res://presentation/battle_narrator.gd") + + +func _init() -> void: + _test_numeric_lines_carry_values() + _test_end_text_differs_by_result() + _test_phase_text_nonempty() + quit(Kit.finish("battle_narrator_test")) + + +func _test_numeric_lines_carry_values() -> void: + var rest: String = NarratorScript.rest_line(12, 30, 40) + Kit.check("休整叙述带入回复量与当前血量(实际「%s」)" % rest, + rest.contains("12") and rest.contains("30") and rest.contains("40")) + var shop: String = NarratorScript.shop_line(77) + Kit.check("商店叙述带入 GP 数(实际「%s」)" % shop, shop.contains("77")) + Kit.check("奖励行与休整行互异(不是同一句套所有场景)", + NarratorScript.reward_line() != rest) + + +func _test_end_text_differs_by_result() -> void: + # 关卡收束(level_end=true)恒为「关卡通关!」——单场胜负文案只在非收束态出现 + var level: String = NarratorScript.battle_end_text("lose", true) + Kit.check("关卡收束态恒为「关卡通关!」(实际「%s」)" % level, level == "关卡通关!") + var win: String = NarratorScript.battle_end_text("victory", false) + var lose: String = NarratorScript.battle_end_text("lose", false) + Kit.check("单场胜负文案互异(胜「%s」/负「%s」)" % [win, lose], win != lose) + Kit.check("胜利文案非空(实际「%s」)" % win, win != "") + var unknown: String = NarratorScript.battle_end_text("something_else", false) + Kit.check("未知结果有兜底文案不吐空串(实际「%s」)" % unknown, unknown != "") + + +func _test_phase_text_nonempty() -> void: + var t: String = NarratorScript.phase_text(1, "", false, false, "") + Kit.check("相位叙述非空(实际「%s」)" % t, t != "") + var boss: String = NarratorScript.phase_text(2, "", false, true, "灰矮人石君王") + Kit.check("Boss 在场时叙述带入 Boss 名(实际「%s」)" % boss, boss.contains("灰矮人石君王")) diff --git a/game/tests/unit/presentation/hud_presenter_test.gd b/game/tests/unit/presentation/hud_presenter_test.gd new file mode 100644 index 00000000..8db3b1f0 --- /dev/null +++ b/game/tests/unit/presentation/hud_presenter_test.gd @@ -0,0 +1,47 @@ +# tests/unit/presentation/hud_presenter_test.gd — HUD 投影直接单测(迭代 29 T5 补洞:该模块此前零指名) +# 断言面 = 纯函数投影:动作结果文案按拒绝原因逐类落字(玩家看到的每句提示都由这里出)、 +# 未知原因有兜底文案(不吐空串)、棋盘变异行投影随表走。期望值由注入内容数出来。 +# 运行:godot --headless --path game --script res://tests/unit/presentation/hud_presenter_test.gd +extends SceneTree + +const Kit := preload("res://tests/unit/test_kit.gd") +const HudScript := preload("res://presentation/hud_presenter.gd") + + +func _init() -> void: + _test_accepted_text() + _test_reject_text_by_reason() + _test_unknown_reason_fallback() + quit(Kit.finish("hud_presenter_test")) + + +func _test_accepted_text() -> void: + var t: String = HudScript.outcome_text({"accepted": true}) + Kit.check("受理态文案固定为「执行完成。」(实际「%s」)" % t, t == "执行完成。") + + +func _test_reject_text_by_reason() -> void: + var cases := { + "not_adjacent": "不能执行:目标与小人不相邻", + "target_revealed": "不能执行:目标已开", + "flag_present": "不能插旗:此格已有旗(旗为一次性承诺——相邻时左键直接挖开)", + } + var bad: Array[String] = [] + for sub in cases: + var got: String = HudScript.outcome_text({ + "accepted": false, "reject_reason": "board_face", "sub_reason": sub, + }) + if got != String(cases[sub]): + bad.append("%s→「%s」" % [sub, got]) + Kit.check("棋盘面各拒绝原因文案逐类正确(错 %d 项 %s)" % [bad.size(), str(bad)], bad.is_empty()) + var ap: String = HudScript.outcome_text({"accepted": false, "reject_reason": "resource_face"}) + Kit.check("资源面拒绝文案为「AP 不足。」(实际「%s」)" % ap, ap == "AP 不足。") + + +func _test_unknown_reason_fallback() -> void: + var t: String = HudScript.outcome_text({"accepted": false, "reject_reason": "brand_new_reason"}) + Kit.check("未知拒绝原因兜底为「动作被拒。」而非空串(实际「%s」)" % t, t == "动作被拒。") + var sub: String = HudScript.outcome_text({ + "accepted": false, "reject_reason": "board_face", "sub_reason": "brand_new_sub", + }) + Kit.check("棋盘面未知子因兜底带前缀可诊断(实际「%s」)" % sub, sub.begins_with("棋盘面拒绝: "))