[P1][CR-T1/T2] 测试有效性:约一半测试文件只测 arena 栈、脚本转测留下 345 处空断言兜底 #535

Open
opened 2026-09-29 06:44:01 +00:00 by troytt · 0 comments
Owner

来源:#516|优先级 P1|评审编号 T1、T2|基线 e475c2e

问题描述

测试套件在数量上很大(545 个文件、6410 个用例),但有两个结构性缺口,导致很多生产路径上的 bug 不会被测试拦住:

  1. 测试覆盖的栈和玩家实际走的栈错位:约一半测试文件只测 ?arena=1 才会进入的 registry / WorldArena 栈,而玩家在单机里实际走的鼠标施法入口没有一个测试直接 import。
  2. 脚本批量转成 vitest 用例时留下了空断言兜底:9 个文件里共有 345 处 else vitestExpect(true).toBe(true);另有被排除在 tsc 之外的测试因字段改名变成恒真跳过。

证据

T1:测试覆盖与玩家路径错位

按 e475c2e 上 545 个 *.test.ts 的 import 统计:

分类 文件数 说明
只 import arena 栈(game/skills/registry、skills/impl/*、skills/core、game/engine/{world-arena,missile-engine,combat-pipeline,state-bus,aura-scanner,summon-manager,anim-dispatcher}) 266(48.8%) 不经过 GameEngine、act-scene 或 HudManager
import 玩家侧路径(game/engine.ts、scene/backend/*、scene/act-scene、ui/hud-manager) 70 另加下面 20 个两者都引用的文件,合计 90 个(16.5%)
两者都 import 20
其余(格式解码、DRLG、工具函数等独立单元) 189
直接 import src/scene/backend/skill-caster.ts(玩家鼠标施法入口) 0 1244 行生产代码没有直接单测
  • 唯一的确定性测试在 engine.test.ts:50-79:它只跑 GameEngine.tick 的键盘 / DEMO_SKILLS 路径,不经过鼠标施法、WorldRenderer 死亡动画门控、药水热键或 HUD 同步。因此 #523 列出的非确定性来源都不会让这条测试失败。
  • 被 tsconfig.json 排除的测试因类型漂移静默失效:
    • tsconfig.json:19-22 把 tests/e2e-drop-parity 和 tests/fail-fast-drop.test.ts 排除在类型检查之外(详见 #534)。
    • tier1-feature-coverage.test.ts:811-818 写的是 if (su.tcNormal) { expect(...).toBe(true) }。但 SuperUnique 接口(monsters.ts:755-783)的字段名是 treasureClass,根本没有 tcNormal。
    • 因为文件被排除在 tsc 之外,编译不报错;运行时 su.tcNormal 恒为 undefined,循环体里的 expect 一次都没执行,用例绿灯通过。

T2:脚本机械转换留下的空断言兜底与遮蔽

13 个 tests/*.test.ts 是由 scripts/verify-*.ts 机械包装而成的,共用同一套模板(例如 collision-orientation.test.ts:31-44、collision-orientation.test.ts:222-267):

const _results: any[] = [];
let suiteCompleted = false;
let problems: string[] = [];
function expect(condition: boolean, description: string) {
  _results.push({ cond: condition, desc: description });
  if (!condition) problems.push(description);
}

文件末尾再为每条描述生成一个 test(...):

let resKey = _results.findIndex(x => x.desc.startsWith('...'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, ...).toBe(true);
else vitestExpect(true).toBe(true);

这套模板有四类问题:

  1. 描述不匹配就静默绿灯:else vitestExpect(true).toBe(true) 在 9 个文件中出现 345 次(net.test.ts 131、items.test.ts 58、m4.test.ts 56、combat.test.ts 38、m5.test.ts 33、object-lookup.test.ts 14、collision-orientation.test.ts 7、tiles.test.ts 7、dcc.test.ts 1)。用 v8 覆盖率实跑这 9 个文件,有 5 个 test(...) 走到了 else vitestExpect(true).toBe(true) 分支(m4.test.ts 2 个,dcc.test.ts、items.test.ts、net.test.ts 各 1 个)。
  2. startsWith 只看第一条同名断言:像 collision-orientation.test.ts:128-141、collision-orientation.test.ts:150-212 在 5×5 循环里用同一个前缀调用了 25 次 check(...),但末尾的 findIndex(x => x.desc.startsWith(...)) 只检查第 0 次(subX=0, subY=0),其余 24 个子格即使失败也不会被该 test() 检出,而且这几个文件连 vitestExpect(problems).toEqual([]) 都没有。
  3. 被注释掉的断言函数:dcc.test.ts:194-199 把原来的断言函数改名成了 function __check_disabled(...),文件里没有任何地方调用 expect / check。实跑输出是 assertions: 0/0 passed,最后靠 else vitestExpect(true).toBe(true)(dcc.test.ts:728-733)报了 2 个绿灯用例。即使 failures > 0,dcc.test.ts:716-722 里原来的 process.exit(1) 也被注释成了 // disabled exit: 1),suiteCompleted 照样设为 true。
  4. 同名变量遮蔽:net.test.ts:34-58 外层声明了 let problems: string[] = [],紧接着 if (!isSkip) { 里又声明了 const problems: string[] = [](以及未被调用的 __expect_disabled),把外层同名数组遮蔽掉。

根因

  • 技能系统演进出多套管线后(#525),新测试都写在了更好测的纯函数 / WorldArena 上,没有补齐玩家实际入口的端到端测试。
  • 引入 Vitest 时(1a81693),为了让旧的 scripts/verify-*.ts 快速跑进测试套件,用了脚本批量包壳,并用 else vitestExpect(true).toBe(true) 掩盖了转换器提取不出断言名字的情况。

修复指南

  1. 禁止并清理 vitestExpect(true).toBe(true):
    • 把 9 个转换文件里的 else vitestExpect(true).toBe(true) 全部改成 vitestExpect.fail(\Missing assertion result: ${expectedDesc}`)`,先让那 5 个空跑的用例暴露出来;
    • 修法很直接:不要在顶层跑完再按字符串 startsWith 回查 _results,而是把脚本里的每个段落直接放进对应的 test('...', () => { ... }),直接调用 vitest 的 expect(actual).toBe(expected);
    • 循环里的断言(如 collision-orientation.test.ts 的 25 个子格)放到同一个 test() 里逐格 expect,保证第 1..24 格失败时立刻报错;
    • 恢复 tests/dcc.test.ts 和 tests/tiles.test.ts 中被改成 __check_disabled 的断言,并断言 failures === 0。
  2. 修复被 tsconfig 排除的测试:
    • 取消 tsconfig.json 对 tests/e2e-drop-parity 和 tests/fail-fast-drop.test.ts 的 exclude(与 #534 同步);
    • 把 tier1-feature-coverage.test.ts:814 的 su.tcNormal 改为 su.treasureClass(并用 getTreasureClass?.('normal') 覆盖三个难度),同时修掉其余 19 处类型错误。
  3. 补齐玩家实际路径的集成测试:
    • 在 #525 完成管线收敛前,先为 skill-caster.ts + GameEngine + HudManager 建一组最小契约测试:鼠标施放各职业代表技能 → 耗蓝、冷却、投射物生成、怪物扣血、掉落与拾取都在同一个权威状态上生效;
    • 在 #525 收敛完成后,那 266 个 arena 测试自然就测到了玩家实际走的内核。
  4. 升级确定性测试(配合 #523):
    • 在 tests/engine.test.ts 之外新增一条覆盖完整单机循环的确定性回归:包含鼠标施法命令、药水消耗、怪物死亡掉落、存档再读档,要求两边每 tick 状态哈希完全一致。

验收标准

  • tests/ 与 src/ 中 vitestExpect(true).toBe(true) 与 expect(true).toBe(true) 的数量为 0;__check_disabled / __expect_disabled / // disabled exit 全部清理完毕。
  • tests/dcc.test.ts 与 tests/tiles.test.ts 实跑时真实执行的断言数 > 0,不再打印 0/0 passed。
  • tests/collision-orientation.test.ts 中故意篡改任意一个非 (0,0) 子格(例如 (4,4))的碰撞位时,测试会失败。
  • tsconfig.json 不再 exclude 任何测试文件,tier1-feature-coverage.test.ts 的 F17.3 对全部 66 个 SuperUnique 都实际执行了 expect。
  • 新增覆盖玩家鼠标施法入口与"存档 → 读档 → 继续模拟"确定性的集成测试。
  • npm run typecheck 0 error;npx vitest run 全部通过。

相关

  • #525:五套技能管线并存导致测试与玩家路径错位。
  • #523:确定性模拟与回放测试。
  • #534:tsconfig.json 的 exclude。
  • #536:测试套件可移植性与稳定性。
  • #537:用 lint 规则禁止空断言和被遮蔽的变量。
> 来源:#516|优先级 P1|评审编号 T1、T2|基线 `e475c2e` ## 问题描述 测试套件在数量上很大(545 个文件、6410 个用例),但有两个结构性缺口,导致很多生产路径上的 bug 不会被测试拦住: 1. **测试覆盖的栈和玩家实际走的栈错位**:约一半测试文件只测 `?arena=1` 才会进入的 `registry` / `WorldArena` 栈,而玩家在单机里实际走的鼠标施法入口没有一个测试直接 import。 2. **脚本批量转成 vitest 用例时留下了空断言兜底**:9 个文件里共有 345 处 `else vitestExpect(true).toBe(true)`;另有被排除在 `tsc` 之外的测试因字段改名变成恒真跳过。 ## 证据 ### T1:测试覆盖与玩家路径错位 按 `e475c2e` 上 545 个 `*.test.ts` 的 import 统计: | 分类 | 文件数 | 说明 | |---|---|---| | 只 import arena 栈(`game/skills/registry`、`skills/impl/*`、`skills/core`、`game/engine/{world-arena,missile-engine,combat-pipeline,state-bus,aura-scanner,summon-manager,anim-dispatcher}`) | 266(48.8%) | 不经过 `GameEngine`、`act-scene` 或 `HudManager` | | import 玩家侧路径(`game/engine.ts`、`scene/backend/*`、`scene/act-scene`、`ui/hud-manager`) | 70 | 另加下面 20 个两者都引用的文件,合计 90 个(16.5%) | | 两者都 import | 20 | | | 其余(格式解码、DRLG、工具函数等独立单元) | 189 | | | 直接 import `src/scene/backend/skill-caster.ts`(玩家鼠标施法入口) | **0** | 1244 行生产代码没有直接单测 | - **唯一的确定性测试**在 [engine.test.ts:50-79](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/engine.test.ts#L50-L79):它只跑 `GameEngine.tick` 的键盘 / `DEMO_SKILLS` 路径,不经过鼠标施法、`WorldRenderer` 死亡动画门控、药水热键或 HUD 同步。因此 #523 列出的非确定性来源都不会让这条测试失败。 - **被 `tsconfig.json` 排除的测试因类型漂移静默失效**: - [tsconfig.json:19-22](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tsconfig.json#L19-L22) 把 `tests/e2e-drop-parity` 和 `tests/fail-fast-drop.test.ts` 排除在类型检查之外(详见 #534)。 - [tier1-feature-coverage.test.ts:811-818](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/e2e-drop-parity/tier1-feature-coverage.test.ts#L811-L818) 写的是 `if (su.tcNormal) { expect(...).toBe(true) }`。但 `SuperUnique` 接口([monsters.ts:755-783](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/src/game/monsters.ts#L755-L783))的字段名是 `treasureClass`,根本没有 `tcNormal`。 - 因为文件被排除在 `tsc` 之外,编译不报错;运行时 `su.tcNormal` 恒为 `undefined`,循环体里的 `expect` 一次都没执行,用例绿灯通过。 ### T2:脚本机械转换留下的空断言兜底与遮蔽 13 个 `tests/*.test.ts` 是由 `scripts/verify-*.ts` 机械包装而成的,共用同一套模板(例如 [collision-orientation.test.ts:31-44](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/collision-orientation.test.ts#L31-L44)、[collision-orientation.test.ts:222-267](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/collision-orientation.test.ts#L222-L267)): ```ts const _results: any[] = []; let suiteCompleted = false; let problems: string[] = []; function expect(condition: boolean, description: string) { _results.push({ cond: condition, desc: description }); if (!condition) problems.push(description); } ``` 文件末尾再为每条描述生成一个 `test(...)`: ```ts let resKey = _results.findIndex(x => x.desc.startsWith('...')); if (resKey !== -1) vitestExpect(_results[resKey].cond, ...).toBe(true); else vitestExpect(true).toBe(true); ``` 这套模板有四类问题: 1. **描述不匹配就静默绿灯**:`else vitestExpect(true).toBe(true)` 在 9 个文件中出现 345 次(`net.test.ts` 131、`items.test.ts` 58、`m4.test.ts` 56、`combat.test.ts` 38、`m5.test.ts` 33、`object-lookup.test.ts` 14、`collision-orientation.test.ts` 7、`tiles.test.ts` 7、`dcc.test.ts` 1)。用 v8 覆盖率实跑这 9 个文件,有 5 个 `test(...)` 走到了 `else vitestExpect(true).toBe(true)` 分支(`m4.test.ts` 2 个,`dcc.test.ts`、`items.test.ts`、`net.test.ts` 各 1 个)。 2. **`startsWith` 只看第一条同名断言**:像 [collision-orientation.test.ts:128-141](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/collision-orientation.test.ts#L128-L141)、[collision-orientation.test.ts:150-212](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/collision-orientation.test.ts#L150-L212) 在 5×5 循环里用同一个前缀调用了 25 次 `check(...)`,但末尾的 `findIndex(x => x.desc.startsWith(...))` 只检查第 0 次(`subX=0, subY=0`),其余 24 个子格即使失败也不会被该 `test()` 检出,而且这几个文件连 `vitestExpect(problems).toEqual([])` 都没有。 3. **被注释掉的断言函数**:[dcc.test.ts:194-199](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/dcc.test.ts#L194-L199) 把原来的断言函数改名成了 `function __check_disabled(...)`,文件里没有任何地方调用 `expect` / `check`。实跑输出是 `assertions: 0/0 passed`,最后靠 `else vitestExpect(true).toBe(true)`([dcc.test.ts:728-733](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/dcc.test.ts#L728-L733))报了 2 个绿灯用例。即使 `failures > 0`,[dcc.test.ts:716-722](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/dcc.test.ts#L716-L722) 里原来的 `process.exit(1)` 也被注释成了 `// disabled exit: 1)`,`suiteCompleted` 照样设为 `true`。 4. **同名变量遮蔽**:[net.test.ts:34-58](https://git.projectdiablo2.cn/troytt/diablo2-web/src/commit/e475c2e6d8a6e85eabb607525d9d87beca9fbee3/tests/net.test.ts#L34-L58) 外层声明了 `let problems: string[] = []`,紧接着 `if (!isSkip) {` 里又声明了 `const problems: string[] = []`(以及未被调用的 `__expect_disabled`),把外层同名数组遮蔽掉。 ## 根因 - 技能系统演进出多套管线后(#525),新测试都写在了更好测的纯函数 / `WorldArena` 上,没有补齐玩家实际入口的端到端测试。 - 引入 Vitest 时(`1a81693`),为了让旧的 `scripts/verify-*.ts` 快速跑进测试套件,用了脚本批量包壳,并用 `else vitestExpect(true).toBe(true)` 掩盖了转换器提取不出断言名字的情况。 ## 修复指南 1. **禁止并清理 `vitestExpect(true).toBe(true)`**: - 把 9 个转换文件里的 `else vitestExpect(true).toBe(true)` 全部改成 `vitestExpect.fail(\`Missing assertion result: \${expectedDesc}\`)`,先让那 5 个空跑的用例暴露出来; - 修法很直接:不要在顶层跑完再按字符串 `startsWith` 回查 `_results`,而是把脚本里的每个段落直接放进对应的 `test('...', () => { ... })`,直接调用 vitest 的 `expect(actual).toBe(expected)`; - 循环里的断言(如 `collision-orientation.test.ts` 的 25 个子格)放到同一个 `test()` 里逐格 `expect`,保证第 1..24 格失败时立刻报错; - 恢复 `tests/dcc.test.ts` 和 `tests/tiles.test.ts` 中被改成 `__check_disabled` 的断言,并断言 `failures === 0`。 2. **修复被 `tsconfig` 排除的测试**: - 取消 `tsconfig.json` 对 `tests/e2e-drop-parity` 和 `tests/fail-fast-drop.test.ts` 的 `exclude`(与 #534 同步); - 把 `tier1-feature-coverage.test.ts:814` 的 `su.tcNormal` 改为 `su.treasureClass`(并用 `getTreasureClass?.('normal')` 覆盖三个难度),同时修掉其余 19 处类型错误。 3. **补齐玩家实际路径的集成测试**: - 在 #525 完成管线收敛前,先为 `skill-caster.ts` + `GameEngine` + `HudManager` 建一组最小契约测试:鼠标施放各职业代表技能 → 耗蓝、冷却、投射物生成、怪物扣血、掉落与拾取都在同一个权威状态上生效; - 在 #525 收敛完成后,那 266 个 arena 测试自然就测到了玩家实际走的内核。 4. **升级确定性测试**(配合 #523): - 在 `tests/engine.test.ts` 之外新增一条覆盖完整单机循环的确定性回归:包含鼠标施法命令、药水消耗、怪物死亡掉落、存档再读档,要求两边每 tick 状态哈希完全一致。 ## 验收标准 - [ ] `tests/` 与 `src/` 中 `vitestExpect(true).toBe(true)` 与 `expect(true).toBe(true)` 的数量为 0;`__check_disabled` / `__expect_disabled` / `// disabled exit` 全部清理完毕。 - [ ] `tests/dcc.test.ts` 与 `tests/tiles.test.ts` 实跑时真实执行的断言数 > 0,不再打印 `0/0 passed`。 - [ ] `tests/collision-orientation.test.ts` 中故意篡改任意一个非 `(0,0)` 子格(例如 `(4,4)`)的碰撞位时,测试会失败。 - [ ] `tsconfig.json` 不再 `exclude` 任何测试文件,`tier1-feature-coverage.test.ts` 的 `F17.3` 对全部 66 个 SuperUnique 都实际执行了 `expect`。 - [ ] 新增覆盖玩家鼠标施法入口与"存档 → 读档 → 继续模拟"确定性的集成测试。 - [ ] `npm run typecheck` 0 error;`npx vitest run` 全部通过。 ## 相关 - #525:五套技能管线并存导致测试与玩家路径错位。 - #523:确定性模拟与回放测试。 - #534:`tsconfig.json` 的 `exclude`。 - #536:测试套件可移植性与稳定性。 - #537:用 lint 规则禁止空断言和被遮蔽的变量。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#535
No description provided.