# Test Infrastructure — Dungeon Sweeper **Engine**: Godot 4.7.2-stable(GDScript 单语言) **Test Framework**: 双载体——`--headless --script` 单元套件 + headless 场景探针;gdUnit4 **deferred**(D2 OQ1 关闭形态——本仓库不引第三方测试框架,需求见下「门禁」节) **门禁**: 本地闸——`tools/run_all_gates.sh`(收口唯一验收入口,按改动域选档 `--code`/`--docs`,跨两域或改闸件本身不带参数=全量;`--quick` 为中段自检)。本仓库**无远端、无 CI**(`.github/workflows/` 已删除,不建不留不参考);闸清单与域划分只在本脚本定义,文档不另列 **Setup date**: 2026-09-12(2026-09-27 `7f24b640` 三层收敛:根 tests/ 并入 `game/tests/`、docs 并入 `design/dev/`、production 解散) **参考实现**: `game/smoke/`(冒烟断言 + 场景探针族——正式工程的活样板;断言计数双口径见下节) ## Directory Layout ``` tests/ unit/ # 隔离单元测试(公式/状态机/纯逻辑)——extends SceneTree + quit(code) integration/ # L2 集成用例(跨系统闭环——与 unit/ 的隔离断言面分工,见下「分层」节) smoke/ # critical-paths.md——人工 gate 清单(/smoke-check 读取) evidence/ # 截图与人工签核记录 run_all.gd # 单元套件统一入口(套件 manifest 协议,D2 AC-2b) static_scan.sh # 静态扫描域 S1–S8 可执行子集 ``` ## 测试三层与承载目录 | 层 | 判什么 | 承载 | 入闸方式 | |---|---|---|---| | **L1 单元** | 最单元的逻辑代码对不对(公式/状态机/纯逻辑) | `tests/unit/` | `run_all.gd` manifest(闸 02) | | **L2 集成** | 跨系统的闭环对不对 | `tests/integration/` | 同上(与单元套件同一入口、同一协议) | | **L3 系统/场景** | 跑起来之后 UI 与场景切换对不对 | `game/smoke/*.tscn` 探针 | `probe_gate.sh`(闸 03/04) | > **integration/ 已建承载**(迭代 29 T3——此前只有章程句无实体,空壳曾于迭代 19 卡① T4 删过一次)。 > 首个集成用例 = `save_rng_roundtrip_test.gd`(存档⇄RNG 快照往返)。章程登记的四类集成用例里, > 回放确定性已由 `tools/parity_anchor.py` 承担并收编为重闸 11 号条件闸件; > Core-Core 与 A1×A2 端到端仍随属主批增量登记;各 GDD 登记的 deferral 集成用例以对侧登记为生效条件。 > > **分层判据**:能不能在 `--script` 模式下不加载 autoload 就验完?能 = L1; > 需要两个以上真实系统协同并走落盘/落库 = L2;需要完整项目引导与场景树 = L3。 ## 断言计数双口径(2026-10-08 迭代 19 卡① T4 订正) 历史 README 写死「752+ 断言/十五套件」必然漂移,改为两种口径并注明语义: - **运行时口径**:`python tools/run_smoke.py` 打印的通过项数(迭代 19 卡①分支基点实测 **840 项 0 失败**,2026-10-08;master 合并卡②④后为 846——随断言面增减浮动,以当次跑测打印为准)——循环内断言按执行次数计。 - **静态口径**:`game/smoke/` 源码 `check(` 调用点计数(2026-10-08 本分支实测 **764 处**)——按源码位置计,一处在循环内即多次执行,故两口径数值不同且各自浮动,禁互相换算或写死。 ## Running Tests ```bash # 单元套件(双载体之一)——Windows 须用 console 版 exe 取 stdout;工作目录 = game/(含 project.godot 那一层) godot --headless --script tests/run_all.gd # 统一入口(本地闸 02 闸调用点) godot --headless --script tests/unit/example_test.gd # 场景探针(双载体之二)——以探针场景为运行目标,走完整项目引导(autoload 在位) godot --headless --path . res://smoke/.tscn # 本地闸必须配外部 wall-clock 超时(引擎无死锁探测;run_all_gates.sh 已内置) timeout 120s godot --headless --script tests/run_all.gd ``` > 单元/契约套件状态(单元测试与契约层批 2026-09-28 立基,后随迭代 7/8 增补):`_suites()` manifest 已登记实跑 **26 套件**(2026-10-16 迭代 29 T1:样板件 `example_test.gd` 退出生产 manifest,27 → 26;`fog_lantern_wiring_test` 随提灯载体退役摘除在前)——data_config(ConfigDB 加载/进度坐标 ceil 解析/fatal 退出码/统一随机流)、core(秘宝结构冻结/断点续玩/提灯装配链)、battle_settlement(DamageMath 纯函数/DamageController 伤害单据五配方)、contracts(系统间接口契约断言)、presentation(节奏队列/奖励浮层/字体资产/地形瓦片集);致命类独立进程 `expect=1`。期望值一律由表读值推导(零玩法数值字面量)、随机只经 RngService 统一流。 **协议要点**(D2 冒烟协议): - 冒烟脚本 `extends SceneTree` 以获得 `quit(code)`;成功 `quit(0)`、失败 `quit(1)` - `--script` 模式**不加载 autoload 且不注册全局标识符**(裸引用 `ConfigDB` = parse error)——ConfigDB/RngService 由脚本显式实例化,core 一律构造注入 - 致命类用例独立进程,runner manifest 登记"调用 → 期望退出码" - 每用例先 `reset_diagnostics()`(诊断收集器协议) - 期望值由表读值推导,不写死玩法数值 ## Test Naming - **Files**: `[system]_[feature]_test.gd`(领域命名,禁 ID 前缀——design/dev/code-conventions.md) - **Functions**: `test_[scenario]_[expected]` - **Example**: `reward_roll_test.gd` → `test_power_interval_rolls_within_bounds()` ## Story Type → Test Evidence | Story Type | Required Evidence | Location | |---|---|---| | Logic | Automated unit test — must pass | `tests/unit/[system]/` | | Integration | Integration test OR playtest doc | 落地时随批建 `tests/integration/[system]/`(目录已删空壳,章程见上) | | Visual/Feel | Screenshot + lead sign-off | `tests/evidence/` | | UI | Manual walkthrough OR interaction test(场景探针为首选) | `tests/evidence/` | | Config/Data | Smoke check pass | `design/qa/` | ## game/smoke/ 游离脚本台账(迭代 29 T7 逐个定性) `game/smoke/` 有 29 个 `.gd`,其中 14 个在闸(探针有 `.tscn` 宿主、被 `probe_gate.sh`/`run_smoke.py` 调用), 其余 15 个**不带 `.tscn`、不入任何闸**——它们是手动入口的测量/诊断件。逐个定性如下, 判据 = 头注自陈用途 + 有无外部引用(工具脚本/产品码/文档)。**存疑一律按在役登记并标注**(不退役)。 | 脚本 | 定性 | 用途与触发方式 | 结果由谁判 | |---|---|---|---| | `adventure_monte_carlo.gd` | **在役**(`CLAUDE.md` 在册入口) | 全冒险期望 MC;`tools/parity_anchor.py` 的对拍载体 | 工具脚本逐位比对 | | `verifier_batch.gd` | **在役**(`CLAUDE.md` 在册入口) | `tools/balance_verifier.py` 的采集端(数值验证) | 报告落 `design/qa/` | | `reward_expectation.gd` | **在役**(`CLAUDE.md` 在册入口) | 奖励期望测量(非封锁格分布/资源类型频次) | 人工读 stdout | | `monte_carlo.gd` | 在役 | 战斗期望 MC(胜轮数/承伤/清图/开格分布) | 人工读 stdout | | `auto_player.gd` | 在役(库件) | 自动玩家策略单一实现——被 `battle_driver` preload | 宿主脚本 | | `battle_driver.gd` | 在役(库件) | 战斗驱动;被 `damage_probe` preload | 宿主脚本 | | `level_autoplay.gd` | 在役(库件) | 被 `presentation/tune_sim_controller.gd` load | 宿主脚本 | | `board_stat_probe.gd` | 在役 | `tools/clear_dominance_audit.py` 的取数端 | 工具脚本 | | `calibrate_boards.gd` | 在役 | 生成器真值分母;`sim-data/analyze.py` 引用 | 工具脚本 | | `damage_probe.gd` | 在役(诊断件) | 零承伤根因分割探针(头注自陈:非验收件·不入闸) | 人工读 stdout | | `defense_scan.gd` | 在役(诊断件) | AC 叠层减伤读数分割(高 AC 轮 vs 基线轮) | 人工读 stdout | | `skill_rhythm_probe.gd` | 在役(分析工具) | 技能池节奏定位(表侧池快照 + 摇号份额) | 人工读 stdout | | `rhythm_batch.gd` | 在役(批量采集) | 技能池节奏批量读数(06 在册项的机器半边) | 人工/工具 | | `dump_battle.gd` | **在役·一次性** | 打印一场完整战斗的步骤流与棋盘摘要(肉眼验收归因质量) | 人工(用完即止) | | `experiment_density.gd` | **在役·一次性** | 不同 mine_density 下的推理率/残余挖掘量取数(历史调参用) | 人工(用完即止) | > **纪律**:本表是这些件的唯一在册处。新增手动测量脚本必须同批登记(用途 + 触发方式 + 结果由谁判); > 退役走 `design/dev/doc-conventions.md` 第 10 节退役规则,禁止「没看见=死件」式清理。 ## Static Scan(提交前本地跑) `tests/static_scan.sh` 实现静态扫描域 S1–S8 的可执行子集;规则全集与动机见 `design/dev/architecture/architecture.md`「测试与质量域」节。新违规案例必须**先补扫描规则再修码**。 ## 本地闸(唯一验收门禁) 本仓库无远端、无 CI——**验收只在本地发生**,跑 `tools/run_all_gates.sh` 是收口唯一入口,按本批改动域选档(`--code`/`--docs`/跨两域或改闸件本身=不带参数全量), 跑完写回执 `game/tests/.gates/receipt.json`(含 `domain` 字段),合回 master 由 worktree-guard 按 diff 推域校验回执。 闸清单与域划分的唯一定义处就是那个脚本,本文档不另列清单(清单散落多份文档正是历史漏跑闸的成因)。 **判定三硬闸**(各闸一律适用,缺一即判定有洞): ①**退出码为唯一权威判据**——日志里的 PASS 串只是日志,不作判据; ②**脚本错误扫描命中即红**——GDScript 的 `SCRIPT ERROR` / `Parse Error` 不改进程退出码、 不计失败数,且会静默中断所在函数的剩余断言,只能由外部扫输出(闸件 `tools/error_scan.py`, 错误标记与 `tools/run_smoke.py` 同源); ③**断言数为零即红**——`test_kit.gd` 的 `finish()` 在零断言时返回专用退出码 2(与「断言失败=1」 区分,避免致命类套件的 expect 协议把它当成预期结果放过)。 **gdUnit4 维持 deferred**:不引入第三方测试框架,本仓库在既有双载体(`--script` 套件 + 探针场景)内解决。 ## 术语(本项目内的唯一语义) | 词 | 语义 | 载体 | |---|---|---| | **冒烟(smoke)** | **秒级存活检查**——能否启动、能否加载表、能否进主屏。判断「这版还活着吗」,不判断「这版对不对」 | `game/smoke/` 的存活面 | | **单元套件** | 隔离的公式/状态机/纯逻辑断言面,逐套件独立进程实跑 | `game/tests/unit/`、`run_all.gd` | | **场景探针** | 以探针场景为运行目标、走完整项目引导(autoload 在位)的 UI/场景断言 | `game/smoke/*.tscn`、`probe_gate.sh` | | **集成用例** | 跨系统测试(Core-Core/回放确定性/存档⇄RNG 往返/端到端) | `game/tests/integration/` | > ⚠ **两处历史遗留命名,读代码时别被字面骗了**:`tools/run_smoke.py` 之名来自冒烟,但**它承载的是 > 系统级断言面**(1127 项检查,远不止存活检查);`game/smoke/` 之名同理,里面既有存活面也有 > 系统级断言与场景探针。判定「是不是冒烟」看语义,不看文件名。