feat(迭代29 T5): L1 覆盖补洞——4 个零指名 core 模块 + 呈现层两件直接单测

新增六套(run_all 27 → 33 套),全部进 manifest,全绿:
· core/board_generator_test(9 条):尺寸与 board_gen 表一致、每个安全格数字恒等于八邻雷数
  (全盘逐格复算)、同种子雷集/数字集逐位一致、可推理性度量与清图判定自洽。
· core/minesweeper_rules_test(12 条):装板带初始扩张揭示且未铺满安全格、安全格余量=总数−已揭示、
  挖已揭示格拒(target_revealed)、挖非相邻格拒(not_adjacent)、fx_reveal 推进揭示并递减余量、
  插旗落旗态与计数、目录外意图被拒。
· core/config_db_resolver_test(8 条):ceil 进度键偏难语义(请求 (1,1) 命中 (1,3) 而非 (1,0))、
  行集按表文件行序返回、ceil 行集、难度乘数 clamp 上下限、未登记坐标返回空修饰。
· core/config_db_cross_validator_test(4 条):正向全对齿零错(防误伤)、悬空 skill_id 必报、
  buff 挂主角侧状态必报(cast_side)。
· presentation/hud_presenter_test(5 条)+ battle_narrator_test(9 条):文案逐拒绝原因正确、
  兜底不吐空串、数值带入、胜负互异、关卡收束态恒定、Boss 名带入。
三屏壳的断言面由 T4 screen_flow_probe 承担(六步流程每步判屏级互斥),不另起第二载体。

变异验证:hud_presenter 的「AP 不足。」改漏句号 → hud_presenter_test 精确见红。
另有一批「写反的断言被实现抓出」的证据(首跑即红、逐条订正后转绿):装板并非零揭示而是带初始扩张、
挖受英雄邻接约束、插旗需 flag_range_free 或相邻且要先给足 AP、技能 type 是 basic 不是 attack、
状态表 id 键是 effect_id 不是 status_id、battle_end_text 在 level_end=true 时恒为「关卡通关!」。

顺带实证本批核心命题:config_db_resolver_test 首跑因诊断回调的 lambda 形参类型写错
(首参是表 id 字符串而非 int)触发 Callable 调用失败——脚本错误不改退出码却让整个解析函数静默
返回 null,套件仍会报「全绿」。正是脚本错误扫描存在的原因。
This commit is contained in:
DKChan committed 2026-10-16 15:55:33 +08:00
1 parent 1458d94b0e
commit d091c98163
7 files changed
+476

No files matched your search

+7
View File
@@ -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}, # 战斗叙述(数值带入/胜负互异/收束态恒定)
]
@@ -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)
@@ -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"))
@@ -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
@@ -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")
@@ -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("灰矮人石君王"))
@@ -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("棋盘面拒绝: "))