第18章 - 测试体系与质量门禁

第 17 章说”跑 content:check 和 audit:levels“,但没说这两条命令各自到底在拦什么、拦不住什么。这一章把质量体系摊开:369 条单测分布在哪些文件、90 条 e2e 各守什么、内容体检与反刷题审计的判定口径、体积门禁怎么量、pnpm verify 为什么把 size 单独串行,以及几条门禁全绿但问题仍在的盲区。

先给结论:这套体系的目标不是”覆盖率数字好看”,而是每个失败分支都有人守,每种”能绕过判定”的走法都被自动跑一遍。数量只是副产品。

18.1 三层门禁:一条命令与两条补充

根 package.json 把门禁拆成三条命令,它们覆盖不同的信任边界:

{
  "scripts": {
    "typecheck": "vue-tsc --noEmit -p tsconfig.json",
    "lint": "eslint . --concurrency auto --cache --cache-location node_modules/.cache/eslint",
    "test": "vitest run",
    "content:check": "tsx scripts/content-check.ts",
    "audit:levels": "tsx scripts/audit-levels.ts",
    "pack:check": "node scripts/check-packaging.mjs",
    "size": "node scripts/check-size.mjs",
    "verify": "node scripts/verify.mjs",
    "e2e": "playwright test",
    "e2e:dist": "pnpm build && playwright test --config playwright.preview.config.ts"
  }
}
命令 守什么 跑在哪
pnpm verify 类型、风格、单测、内容、判定审计、打包、产物体积 CI 的 verify job;release 也跑
pnpm e2e 真实浏览器里的可玩性(dev server) CI 的 e2e job
pnpm e2e:dist 生产产物:懒加载、base: './'、构建期 CSP 注入 CI 的 preview job

三条缺一不可,理由很具体:verify 里的单测跑在 Node 环境,看不到 DOM 与 Phaser;e2e 跑在 Vite dev server 上,不经过生产构建的 transformIndexHtml。历史上那次生产白屏就是”dev e2e 全绿 + 体积达标”同时成立的,详见第 20 章。

# 日常提交前
pnpm verify

# 改过交互、路由、存档、CSP、构建配置时,再补这两条
pnpm e2e
pnpm e2e:dist

18.2 单测:369 条 / 25 个文件的分布

vitest.config.ts 只有 14 行,但 include 的注释是整份配置里最重的一块:

export default defineConfig({
  test: {
    include: [
      'packages/**/src/**/*.test.ts',
      'games/**/src/**/*.test.ts',
      // 外壳里也有纯逻辑(路由解析、seed、存档接线)。不纳入 include 的测试 =
      // 静默不执行,比没写更糟:CI 会假绿。外壳测试必须是纯逻辑(node 环境,不碰 DOM)。
      'apps/site/src/**/*.test.ts',
    ],
    environment: 'node',
  },
});

按源码统计(it() 静态计数 + content.test.ts 里那条按 20 关展开的循环),当前是 369 例 / 25 个文件:

区域 文件数 例数 代表文件与它守的东西
六个模板 6 144 rule/logic.test.ts(31)、construct(28)、experiment(27)、pattern(25)、simulate(18)、argue(15)
证据 3 41 narrative.test.ts(25)、badges.test.ts(9)、report.test.ts(7)
存档 2 27 manager.test.ts(15)、save.test.ts(12)
内容 1 34 content.test.ts:15 个 it,其中一条按 SHIPPED_LEVEL_IDS 逐关生成
纯逻辑工具箱 3 43 pattern/index.test.ts(24)、stats.test.ts(10)、rng.test.ts(9)
工具 UI 逻辑 1 12 metricsRows.test.ts:快照行解码
画布助手 2 20 draw.test.ts(13)、theme.test.ts(7),都不需要 Phaser 运行时
平台层 1 13 web.test.ts:无 IndexedDB 降级、乱序写入、证据裁剪并入聚合
i18n 1 12 index.test.ts:中英 key 1:1、英文值零 CJK
玩法包一致性 1 8 forge.test.ts:manifest 与内容、子路径导出三方对账
外壳 3 14 routePath.test.ts(7)、csp.test.ts(4)、seed.test.ts(3)
文案英文覆盖 1 1 i18n-coverage.test.ts:扫模板源码里的中文整句

这张表里最值得学的是三处”测试形态”的选择:

(1)失败类用内容里的负例驱动,不在测试里手写。 pattern/logic.test.ts 的做法是逐条跑内容里的负例,断言失败类名一致:

it('rejects negative samples with declared classes', () => {
  for (const negative of detail.negativeSamples) {
    const verdict = validateArtifact(script, base, negative.artifact);
    expect(verdict.passed).toBe(false);
    expect(verdict.failure?.class).toBe(negative.expectFailure);
  }
});

这样内容一改,单测自动跟着内容走,不会出现”测试里写死一版负数、内容换了另一版”的脱节。

(2)纯逻辑与渲染分开测。 命中区的坐标数学在 packages/toolkit 与 packages/canvas-kit 的纯函数里测,真实点击留给 e2e。第 13 章讲过那条教训:createGame.ts 依赖 Phaser,所以画布相关单测只碰 theme.ts / draw.ts 这种”纯常量 + 纯助手”。

(3)存档迁移用”坏数据”测。 save.test.ts 直接喂一份字段全乱的存档,断言”坏字段只丢自己”:

const migrated = migrate(
  {
    schemaVersion: 1,
    updatedAt: 'yesterday',
    profile: { nickname: '  ', startBand: 'not-a-band', createdAt: 'x' },
    levels: {
      [LEVEL_ID]: {
        completed: true,
        completedVariants: ['base', '', 42, 'base'],
        stamps: ['constraint', 'not-a-stamp', 'constraint'],
        // ……
      },
    },
  },
  NOW,
);
expect(migrated.updatedAt).toBe(NOW);

存档测试不测”正常路径”——正常路径在 e2e 里;单测专门测”用户换了浏览器、旧数据被改坏、未来版本回空档”这类只在真实使用中出现的事。

模板文案的英文覆盖是另一个值得抄的模式:它不像普通断言那样写死期望值,而是扫描源码,把模板里的中文整句抽出来查英文兜底表:

// packages/templates/src/i18n-coverage.test.ts
for (const match of source.matchAll(/['"]([^'"\n]*[\u4e00-\u9fff][^'"\n]*)['"]/g)) {
  const text = match[1] as string;
  if (text.length < 4 || FRAGMENT.test(text) || EN[text]) continue;
  missing.push(text + '(' + file.split(/[\\/]/).pop() + ')');
}

它只查整句:拼接片段(" / 守 "、" 条边 · 成本 ")首尾带空白或以标点开头,不是可查表的文案,先跳过。这条测试是”新的中文文案没给英文就红”的机器化实现,比人工校读可靠。

18.3 e2e:90 条 / 6 个文件,各守一层

playwright.config.ts 与 playwright.preview.config.ts 只有 baseURL 与 webServer 不同:一个起 pnpm dev(5192),一个起 pnpm preview(5193,读 dist)。两者都设 workers: 1、fullyParallel: false、trace: 'retain-on-failure',因为关卡共享 IndexedDB,并行会互相污染存档。

90 条的分布:

文件 条数 守什么
level.spec.ts 7 首关:进入、autoplay 落盘、无工具被拒、真实点击 ≤6 次、画布点一格、断网可玩且零外网、刷新后完成状态仍在
m1.spec.ts 52 SHIPPED_LEVEL_IDS 逐关两条(autoplay、无工具被拒,19 关 ×2=38)+ 14 条真实点击
budget.spec.ts 20 每关交互次数预算:≤ target + 1
ui-smoke.spec.ts 8 8 个工具面板”关键动作后可见区必须变化”
narrative.spec.ts 1 完成两关后日志、徽章、报告可用;导出证据包再只读导入
i18n.spec.ts 2 切英文并持久化;英文界面下证据与报告也是英文

三类用例的写法差异很大,正好对应三种不同的”守”:

autoplay 用例只验”这关能通”:

test('M1 autoplay:' + levelId, async ({ page }) => {
  await page.goto('/#/play/' + levelId + '?autoplay=1&instant=1');
  expect(await waitEvent(page, 'level:complete:' + levelId, 30_000)).toBe(true);
  await expect.poll(async () => {
    const save = await saveSnapshot(page);
    return save?.levels?.[levelId]?.completed === true;
  }, { timeout: 20_000 }).toBe(true);
});

它走的是运行时 autoplay(),和真实操作同一条 dispatch 路径,但绕开了 UI——所以它证明”判定与动作序列自洽”,不证明”玩家点得到”。这个边界后面 18.8 还要再说一次。

真实点击用例必须按设计坐标换算,不能点 DOM 中心——画布里的格子根本不是 DOM 元素,整个画布只有一个 <canvas>。shared.ts 的做法是按画布实际矩形把设计坐标换算成屏幕像素,再点格子中心:

// e2e/shared.ts
export async function clickCanvasCell(page: Page, index: number): Promise<void> {
  const point = await designToScreen(page, { width: 900, height: 560 }, 70 + index * (56 + 8) + 56 / 2, 320 + 76 / 2);
  await page.mouse.click(point.x, point.y);
}

交互预算用例把”最少几次点击”变成断言,防的是交互退化:

for (const budget of BUDGETS) {
  test('交互预算:' + budget.id + ' ≤ ' + (budget.target + 1), async ({ page }) => {
    await page.goto('/#/play/' + budget.id);
    await startInteractionMeter(page);
    await budget.run(page);
    await page.getByTestId('level-submit').click();
    await expect(page.getByTestId('completion-panel')).toBeVisible();
    const used = await interactions(page);
    expect(used, budget.id + ' 用了 ' + used + ' 次交互').toBeLessThanOrEqual(budget.target + 1);
  });
}

计数不是在业务代码里埋点,而是在页面里挂捕获阶段的 click / change 监听——不侵入实现,也不会漏掉组件内部处理的点击。留 +1 余量是给音效、焦点等实现差异的。

UI 冒烟用例是专门补盲区的一条,ui-smoke.spec.ts 的注释把动机写清楚了:

/**
 * UI 冒烟门禁(docs/12 §7-7 / P1-3 遗留项):
 * **关键动作之后,可见区必须发生变化**。目的是拦住两类问题:
 *   1. "死按钮"——点了屏幕完全不变(历史上 inference/metrics/queue 的"查看区间/指标"就是这样);
 *   2. "运行完没反馈"——调完参数点运行,指标区还是旧值。
 */

实现方式很朴素:动作前后各取一次面板 innerText,断言两者不同。它不看截图,所以不会因为字体或渲染差异误报;但它只能证明”有变化”,不能证明”变化是对的”——后者仍靠真实点击用例与人工走查。

18.4 content:check:内容合规的唯一构建期闸门

scripts/content-check.ts 271 行,只做通用检查,不写死某关的解法数值(否则内容一改脚本就红)。它逐个已发布关卡做这些事:

组 检查
身份 详情 id 与目录 id 一致;摘要/详情的模板、学段、主线、标题、工具列表逐项比对
双语 六个英文字段齐全、零 CJK;英文文案不许出现全角标点 ,。;:!?()【】
模板 levelScript 能被该模板的 parseScript 解析;requiredToolArtifacts 都有工具声明产出
解 ≥2 条黄金解,id 唯一,逐条过 validateArtifact
负例 ≥3 条,id 唯一,必须不通过且失败类与声明一致
变体 ≥2 个,id 唯一,patch 可被 applyVariant 解析;两个变体同轴时只警告
反馈 可达失败类 ⊆ feedback 的键;messageKey / hintKey / focus 都不能空
证据 evidencePlan ≥2 阶段,id 唯一,require 与 descriptionKey 非空
人工 manualAudit.reviewed === true 且 reviewedAt 是 ISO 日期
文案 禁词分两级:伤害性词(失败/惩罚/笨蛋/输了/淘汰/粗心/扣分/排名/倒数)→ 错误;建议替换词(必须/聪明/笨)→ 警告

几个细节能看出”门禁不是越严越好,而是要有依据”:

  • 英文标点只拦全角,弯引号 “” 与 en dash – 是合法英文排版,不拦;
  • 中文句子直接当 messageKey 时只记警告而不是错误——这是 i18n 迁移期的过渡写法,等收口后再升级;
  • 变体只要求”≥2 个且轴合法”,不要求覆盖两条不同的轴,因为 docs/02 §7.5 本来就没这么要求,凭空加硬约束会让内容作者为难。

最后还有矩阵层检查:LEVEL_SUMMARIES 里的 id 必须在 SHIPPED_LEVEL_IDS 里、unlock.after 不能引用自己或不存在的关卡、已发布清单不能缺摘要。加 --require-all 时,LEVEL_IDS 与 SHIPPED_LEVEL_IDS 必须相等:

pnpm content:check --require-all
# 目标矩阵:20/20 已发布;其余 0 关在后续里程碑(已要求全量)。

CI 的 verify job 在跑完 pnpm verify 之后额外跑这条带开关的命令(verify 自己跑的是不带开关的版本),这样本地开发可以只关心已发布关,而主干必须 20/20。

18.5 audit:levels:反刷题审计的 A 项

内容体检回答”这关写得对不对”,反刷题审计回答”这关能不能被绕过”。scripts/audit-levels.ts 对每关跑 A1/A1b/A2/A3/A4/A6/A8 七项,加两个人工资格检查:

项 断言 为什么需要
A1 无工具直接 submit 必须失败,且 missingEvidence 非空 防”不碰工具也能交”
A1b 空产物 {} 走 validateArtifact 必须被结构反馈拒绝,且 missingEvidence 为空 防”把结构错误伪装成缺步骤”,这也是第 20 章里”门禁先于判定”那处缺陷的机器守卫
A2 actionsFor 至少含一个非选择动作(非 submit/run/reset) 防”选择题换皮”;只有一种非选择动作时降级为警告
A3 ≥2 条有效解,且产物 JSON 互不相同 防”两条解其实是同一份答案”
A4 负例逐条触发声明的失败类;可达失败类 ⊆ feedback;≥3 类有负例 防”声明了失败类却没有玩家能遇到”
A6 变体 ≥2 且 patch 可解析;transferVariants 逐条用黄金解验证通过 防”迁移门恒真”
A8 evidencePlan ≥2 阶段;autoplay() 真的走到 passed,检查点全满足,且产生过证据草稿 防”证据计划只写在 JSON 里”
A5/A10 只检查 manualAudit.reviewed 与日期合法 判断”这关是不是一道选择题”必须靠人,脚本只能要求签字

A2 和 A3 的判定逻辑值得抄,因为它们是”把抽象要求写成几行代码”的范例:

/** 纯"选择"动作:只有这些动作的关卡等于选择题换皮(A2 要拦的正是它)。 */
const CHOICE_ONLY = new Set(['submit', 'run', 'reset']);

const actions = verifier.actionsFor(detail, baseVariant, detail.solutions[0]?.artifact ?? {});
const nonSubmit = actions.filter((action: LevelAction) => !CHOICE_ONLY.has(action.type));
const kinds = new Set(nonSubmit.map((action: LevelAction) => action.type));
if (nonSubmit.length === 0) fail(id + ' A2:动作空间只有选择类动作(' + [...] + ')');
else if (kinds.size < 2) warn(id + ' A2:非选择动作只有 ' + [...kinds].join('、') + ' 一种,建议至少两条操作路径');

A8 是唯一”真的跑一遍运行时”的审计项,它不只看 validateArtifact,而是 await runtime.autoplay() 之后读快照:

const drafts: EvidenceDraft[] = [];
const runtime = runtimeFor(detail, (draft) => drafts.push(draft));
const auto = await runtime.autoplay();
const snapshot = runtime.snapshot();
if (!auto.ok) fail(id + ' A8:autoplay 返回失败(' + (auto.message ?? '?') + ')');
else if (!auto.verdict?.passed) fail(id + ' A8:autoplay 没通过(' + (auto.verdict?.failure?.class ?? 'missing-evidence') + ')');
else if (snapshot.phase !== 'passed') fail(id + ' A8:autoplay 通过但 phase=' + snapshot.phase);

这就是为什么 audit:levels 比单测”更接近真实”:它驱动的是同一个 LevelRuntime,只是操作来自 actionsFor 而不是鼠标。

18.6 pack:check 与体积门禁:只量真实存在的东西

pack:check 的注释先立规矩:

/**
 * 工程/打包体检:只检查**仓库里真实存在的东西**。
 * 原生壳(Tauri/Capacitor)尚未创建,所以这里刻意不检查 tauri.conf.json / capacitor.config.ts:
 * 检查不存在的文件只会制造"永远红"或"永远假绿",两者都坏。壳落地后把检查项加进来即可。
 */

它检查:根脚本齐全(dev/build/typecheck/lint/test/content:check/audit:levels/pack:check/size/verify/e2e/e2e:dist)、pnpm-workspace.yaml 存在、站点入口 apps/site/index.html 引用 /src/main.ts 且无外链、Phaser 裁剪版与 MIT 许可就位、20 关摘要/详情的 id 与英文字段一致、每关有 manualAudit;原生壳目录不存在时给提示而非假装通过。

体积门禁用 check-size.mjs,预算是文件顶部的常量:

const BUDGETS = {
  initialKb: 85,        // 首屏 JS+CSS,gzip
  initialRawKb: 260,    // 首屏 JS+CSS,原始
  chunkKb: 280,         // 最大懒加载 chunk,gzip
  chunkRawKb: 1100,     // 最大懒加载 chunk,原始
  distKb: 4000,         // dist 总量
  assetKb: 200,         // 单个非代码资源
  assetsKb: 600,        // 非代码资源合计
};

它能区分四类对象,这是关键——不能只量 dist 总量:首屏引用的文件(从 index.html 的 src/href 反解)、内联脚本与样式、懒加载 chunk、非代码资源。2026-11-04 的实测:

项 实测 预算
首屏 JS+CSS(gzip) 69.5 KB 85 KB
首屏 JS+CSS(原始) 203.6 KB 260 KB
最大懒加载 chunk(vendor-phaser) 220.3 KB gzip / 849.5 KB 原始 280 / 1100 KB
dist 总量 61 个文件 / 1518.9 KB 4000 KB

更难的是它先做产物结构体检,再量体积:

// ---- 产物结构体检(先做,避免"体积很小但整页白屏"的假绿)----
// 背景:CSP 注入曾用正则替换 </head>,命中 index.html 注释里的同名字面量,
// 结果 script 标签与被注入的 meta 一起进了注释,页面只有空 #app。
const htmlWithoutComments = html.replace(/<!--[\s\S]*?-->/g, '');
// 剥掉注释后必须仍有可执行的 module script 与 CSP meta

这一段是”从事故里长出来的门禁”的典型:体积门禁原本只量大小,白屏的产物体积反而更小,所以永远绿。加了结构检查之后,同类问题会在 pnpm size 这一步就暴露,而不用等 e2e:dist 的 90 条全部超时。

18.7 verify 的编排:并行七步,size 单独串行

scripts/verify.mjs 只有 56 行,核心是两段:

const STEPS = [
  { name: 'typecheck', command: 'pnpm typecheck' },
  { name: 'lint', command: 'pnpm lint' },
  { name: 'test', command: 'pnpm test' },
  { name: 'content:check', command: 'pnpm content:check' },
  { name: 'audit:levels', command: 'pnpm audit:levels' },
  { name: 'pack:check', command: 'pnpm pack:check' },
  { name: 'build', command: 'pnpm build' },
];
// ……
const results = await Promise.all(STEPS.map((step, index) => run(step.name, step.command, COLORS[index % COLORS.length])));
const failed = results.filter((result) => result.code !== 0);
if (failed.length > 0) { /* 报失败并退出 1 */ }

const size = await run('size', 'pnpm size', COLORS[0]);
if (size.code !== 0) { /* 体积门禁失败 */ }

为什么 size 不能并行:它读的是 apps/site/dist,而 build 正在写同一个目录。并行跑会读到上一轮产物,出现”体积很小但其实是陈的”假绿。这条教训在 docs/02 §14.1 里写着,代码里就是一行 await——先 Promise.all 等七步,再单独跑第八步。

输出也不是黑箱:每个子进程的 stdout/stderr 按行加 [步骤名] 前缀,颜色区分步骤。失败了能看到是哪一步、哪一行。

CI 侧(.github/workflows/ci.yml)把它拆成三个 job:

jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - run: pnpm install --frozen-lockfile
      # typecheck → lint → 单测 → content:check → audit:levels → pack:check → build → size
      - run: pnpm verify
      - name: 内容矩阵必须 20/20
        run: pnpm content:check --require-all

  e2e:
    needs: verify
    # ……安装浏览器后 pnpm e2e

  preview:
    needs: verify
    # ……构建后 pnpm e2e:dist

三个 job 的分工是刻意的:verify 15 分钟超时,管”代码与内容对不对”;e2e 45 分钟,管”真实浏览器能不能玩”;preview 30 分钟,管”生产产物能不能加载”。后两个 needs: verify,所以类型或内容出错时不会白跑一小时浏览器。失败时两个 job 都上传 playwright-report/ 与 test-results/,retention-days: 7。

18.8 门禁的盲区:全绿不等于没问题

这套体系是”从事故里长出来”的,所以它清楚自己漏什么。写代码前值得先读这段,避免把”绿”当成”对”:

盲区 为什么门禁发现不了 现在的兜底
autoplay 绕开 UI A8 走 runtime.autoplay(),动作来自 actionsFor,不经过任何控件 ui-smoke.spec.ts(关键动作后必须变化)+ budget.spec.ts(次数)+ 14 条真实点击
失败反馈”写了但玩家遇不到” content:check 与 A4 只验”可达失败类 ⊆ feedback”(声明层),不验 UI 能不能显示 第 20 章的 Tier 0 改造把 submit 改成先结构校验;但没有自动断言”故意做错时显示的是内容里的那句话”
没有视觉回归基线 不开像素比对,截图只在失败时保留 五视口人工走查(1280×800 / 1366×768 / 1024×768 / 834×1112 / 390×844)
没有性能基准 只有构建期体积门禁;docs/02 里写的 bench-levels.mjs 不存在 无。发布说明不得引用”性能达标”
没有 axe 可访问性扫描 未安装 axe-core;组件测试也没接 @vue/test-utils 人工走查;键盘路径有 e2e 覆盖(成人门长按用 Space 替代)
文档与实现会漂移 门禁不读 docs/ 靠人工;已知例子:release.yml 的发布说明仍写”120 单测”,实际是 369

倒数第二条最容易被忽视:“测试没被 include”和”没写测试”一样危险。vitest.config.ts 的注释之所以强调”不纳入 include 的测试 = 静默不执行,比没写更糟”,是因为外壳那三个测试(routePath / seed / csp)曾经写过但压根没跑,CI 一路假绿。

同样值得记住的是交互简化那轮(docs/12)的教训:交互预算与 UI 冒烟这两条门禁是新加的,加之前 20 关的”27 击、死按钮、运行完没反馈”全部能过 verify。门禁的覆盖面本身也需要定期用”故意做坏一次”来检验——这是本章最想传达的方法论。

小结

质量体系可以压缩成一条链:

  • pnpm verify 并行七步(typecheck / lint / test / content:check / audit:levels / pack:check / build),最后串行 size,因为体积门禁必须读本轮产物;
  • 单测 369 例 / 25 文件,覆盖模板判定、工具箱纯逻辑、证据与存档、平台降级、i18n 一致性、外壳路由与 CSP;失败类用内容负例驱动,文案英文覆盖用源码扫描;
  • e2e 90 条 / 6 文件,autoplay 守”能通”、真实点击守”点得到”、预算守”不多点”、UI 冒烟守”屏幕有变化”、narrative 守”跨页面数据链”、i18n 守”切语言不烂”;
  • content:check 管内容自洽与双语,audit:levels 管判定不可绕过,A1b 专门防”结构错误伪装成缺步骤”;
  • pack:check 只验真实存在的文件,check-size 先做产物结构体检再量体积,量四类对象而不只是 dist 总量;
  • 盲区(UI 可达性、视觉回归、性能基准、a11y 扫描、文档漂移)已经列明,别把”绿”当成”对”。

下一章进入构建与发布:base: './' 如何让产物能放进任意子目录、CSP 为什么只能在构建期注入、release.yml 目前实际做到哪一步、以及哪些”三端打包”的说法目前还不能宣称。