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

13.1 六层防线总览

CHAGS 的质量保障不是一个测试套件,而是六层互补的防线,每层有明确的分工:

层 工具 覆盖 运行方式
类型 vue-tsc --noEmit 全仓 strict,含 .vue 单文件组件 pnpm typecheck
风格与禁令 eslint 10(flat config) 代码风格 + 逻辑层禁用随机与隐式时钟 pnpm lint
逻辑单测 Vitest 模板逻辑、游戏关卡数据、注册表、文案表、复习调度 pnpm test
内容校验 自研 CLI 内容 schema、模板脚本、双语完整性、漏翻扫描 pnpm content:check
体积守卫 scripts/check-size.mjs 首屏 / 单 chunk / 资源总量预算 pnpm size(verify 内)
端到端 Playwright ×3 配置 真实操作通关、autoplay、双语、慢设备、版面巡检 pnpm e2e / e2e:dist / audit:i18n

前五层全部挂在一条命令下:

pnpm verify = typecheck → lint → test → content:check → build → size

对 AI 协作来说,这条命令就是「完成」的定义 —— 全绿之前,改动不算做完。

13.2 单元测试:Vitest

根配置只有几行,但包含范围是全仓:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    // apps 下的纯逻辑测试也要跑 —— 例如典故注册表与内容目录的一致性
    include: [
      'packages/**/src/**/*.test.ts',
      'games/**/src/**/*.test.ts',
      'apps/**/src/**/*.test.ts',
    ],
    environment: 'node',
  },
});

environment: 'node' 是刻意的:单元测试不碰 DOM、不碰 WebGL、不碰浏览器 —— 能跑单测的地方,说明逻辑真的从渲染里分离出来了(铁律一)。

写作本文时实测:23 个测试文件、156 项用例全部通过,总耗时不到 1 秒。测试分四类:

1)模板逻辑(packages/templates/src/*.logic.test.ts)

纯函数、边界、退化输入。以 place.logic.test.ts 的 12 项为例,覆盖「最近槽位查找」「容差外不放」「重复归位去重」等。

2)游戏关卡数据(games/*/src/logic.test.ts)

每个典故一组,验证「关卡脚本本身是对的」。盘古的例子:

describe('盘古开天辟地 · 关卡数据', () => {
  const raw = getAllusion('pangu');

  it('典故被标记为神话,排在信史之前', () => {
    expect(raw?.kind).toBe('myth');
    expect(raw!.yearApprox).toBeLessThan(-1000);
  });

  it('撑天要 30 次点击,正好点满一万八千年', () => {
    const level = tapLevel(raw!);
    expect(level.targetValue).toBe(18000);
    expect(level.mode).toBe('tap');
    expect(tapsNeeded(level)).toBe(30);
  });

  it('模板 schema 会拒绝点不满的撑天脚本', () => {
    const broken = { /* targetValue 改成 18500,凑不成整数倍 */ };
    expect(() => tapLevel(broken)).toThrow();
  });
});

注意最后一条:它不只验证「好数据能过」,还验证「坏数据必须被拒」—— schema 的 refine 规则本身有测试。

3)基础设施一致性(apps/site/src/registry.test.ts)

这是最值钱的一组测试之一。典故 id 曾经抄在四个地方(内容 JSON、allusions/index.ts、注册表、e2e),新增第 8 个时全绿,用户点进去才 404。现在一致性由单测强制:

describe('典故注册表', () => {
  it('内容里有几个典故,注册表里就要有几个', () => {
    expect(Object.keys(registry).sort()).toEqual(allusions.map((a) => a.id).sort());
  });
  it('注册表里的每个 id 都能对应到一份内容', () => { /* ... */ });
  it('每个 id 都有懒加载入口(不是 undefined)', () => { /* ... */ });
});

4)文案与调度(packages/i18n、packages/progress)

  • messages.test.ts:全键无空串、中英文案不相同(防复制粘贴漏翻)、取词器回落、?lang= 解析边界;
  • review.test.ts:升级 / 降级 / 封顶 / 下限、gradeFromScore 的分档边界(9 项,时钟注入,不依赖真实时间)。

13.3 ESLint:把纪律写进配置

eslint.config.js 是 flat config,除常规规则外有三处关键配置。

逻辑层禁令(把铁律写进 lint)

{
  // 纯逻辑层必须可控、可复现:禁止随机与隐式时钟
  files: ['packages/progress/src/**/*.ts', '**/logic/**/*.ts'],
  rules: {
    'no-restricted-properties': [
      'error',
      { object: 'Math', property: 'random',
        message: '纯逻辑层禁止 Math.random,请注入种子随机数,保证测试可复现。' },
      { object: 'Date', property: 'now',
        message: '纯逻辑层禁止 Date.now,请通过参数注入 now()。' },
    ],
  },
},

这条规则与 ProgressManager 的时钟注入(第 10 章)是配套的:纪律不靠自觉,靠配置。

Vue 规则的松紧取舍

rules: {
  'no-undef': 'off',                          // TypeScript 已负责未定义变量检查
  'vue/multi-word-component-names': 'off',
  // 纯排版规则,交给编辑器/Prettier,不进 lint 门禁
  'vue/max-attributes-per-line': 'off',
  'vue/singleline-html-element-content-newline': 'off',
  'vue/html-self-closing': 'off',
},

忽略清单

**/dist/**、**/node_modules/**、apps/desktop/src-tauri/target/**(Tauri 目标目录里嵌入了压缩后的前端产物,会让 eslint 报几千条)、apps/mobile/android/**、.scratch/** 等。忽略清单的注释写明了每一项的原因 —— 忽略是一门需要解释的手艺。

13.4 内容校验:pnpm content:check

第 6 章与第 11 章分别介绍过内容与翻译两个方向,这里给出全景。脚本 packages/templates/src/cli/check-content.ts 的检查项:

  1. 内容 schema 本身由 allusions/index.ts 在 import 时强制(能不通过就根本跑不到这里);
  2. 每段关卡脚本必须符合声明的模板(查 templateSchemas 注册表):
  ✗ substitution 脚本 stones: 用这些石头凑不出重物的重量(容差内无解),关卡不可通关
  1. 关键抉择的 beatId 必须在故事里、必须有 transfer 题;
  2. 每部典故打印一行概要(实测输出):
· 大禹治水 (dayu)
  · 传说 / 2 幕故事 / 3 道复习题 / 2 段玩法:tap → trace
  1. 翻译体检:缺译文、结构不一致、数组长度不一致、合并后超 schema、合并后关卡脚本重新过模板 schema、CJK 泄漏扫描;
  2. 有任何一项失败:process.exit(1) —— 构建与 CI 直接红。

它的输出对内容作者非常友好:每一行都定位到具体典故与字段路径,不需要读源码就能改内容。

13.5 体积守卫:pnpm size

scripts/check-size.mjs 的判定方式很关键:读构建产物 index.html 里实际引用的资源 = 首屏必须下载的,assets/ 里其余文件 = 按需加载的:

const BUDGETS = {
  /** 首屏 JS + CSS(gzip)—— 决定用户多久能看到典故馆 */
  initialKb: 150,
  /** 单个懒加载 chunk(gzip)—— 目前最大的是 Phaser */
  chunkKb: 450,
  /** assets 目录原始大小 */
  totalKb: 3500,
};

输出分两组:

首屏必须下载的:
  · assets/index-xxxx.js  xx.x KB (gzip)
  · assets/index-xxxx.css  x.x KB (gzip)
check 首屏 JS+CSS 合计:xx.x KB / 预算 150 KB
按需加载的 chunk:
  · assets/phaser-xxxx.js  3xx KB (gzip)
  · assets/pangu-xxxx.js    x.x KB (gzip)
体积在预算内 ✅

超预算时给出明确出口:要么优化,要么在脚本里调整预算并说明原因 —— 不允许悄悄绕过。这个脚本本身是一次审查补上的:技术方案写了「用 size-limit 守卫体积」,但实际上没有任何东西在守,属于「文档漂移」,修复方式是加脚本并接进 verify。

13.6 端到端:Playwright 三套配置

端到端分三个配置,各管一段:

配置 起什么服务 跑什么
playwright.config.ts 开发服务器 :5183(复用已有进程) 冒烟、真实操作、autoplay、双语、慢设备、截图;忽略 preview 与 audit
playwright.preview.config.ts pnpm build + preview :5199 生产产物上的用例(验证代码分割与懒加载没有被构建改变)
playwright.audit.config.ts 开发服务器 双语版面巡检(34 张截图到 .scratch/i18n-audit/)

默认配置的骨架:

export default defineConfig({
  testDir: './e2e',
  testIgnore: ['preview.spec.ts', 'audit.spec.ts'],
  timeout: 90_000,
  // 失败时留下截图与轨迹:CI 上挂了才有东西可查
  use: {
    baseURL: 'http://127.0.0.1:5183',
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'pnpm --filter @chags/site exec vite',
    url: 'http://127.0.0.1:5183',
    reuseExistingServer: true,
  },
});

坐标换算:设计分辨率 → 屏幕坐标

游戏是 Canvas,Playwright 只能点屏幕像素。e2e/helpers.ts 提供整套换算,它是 Scale.FIT + CENTER_BOTH 的逆运算:

export async function toScreen(page: Page, x: number, y: number) {
  const box = await page.locator('canvas').boundingBox();
  if (!box) throw new Error('canvas 还没出现');
  const scale = Math.min(box.width / DESIGN.width, box.height / DESIGN.height);
  const ox = box.x + (box.width - DESIGN.width * scale) / 2;
  const oy = box.y + (box.height - DESIGN.height * scale) / 2;
  return { x: ox + x * scale, y: oy + y * scale };
}

基于它的四个动作原语:

helper 模拟的操作
clickDesign(page, x, y) 点击设计坐标
dragDesign(page, from, to) 真·拖拽(按下 → 中间点 → 松手,分步移动)
holdDesign(page, x, y, ms) 按住不放(夸父的奔跑、太公的等待)
tapDesign(page, x, y, times, gap) 连点(精卫、愚公)

再加两个观测函数:events(page) 读 window.__chags.events,lastError(page) 读最近一次载入失败详情。

用例的四种形态

① 真实操作通关(每部游戏一条,共 12 条)。曹冲的例子:

test('真实操作通关:拖大象上船 → 刻水痕 → 搬石头 → 称出重量', async ({ page }) => {
  await page.goto('/#/play/caochong-elephant');
  await expect(page.locator('canvas')).toBeVisible();
  await expect.poll(() => events(page)).toContain('session:start');

  // ① 试着直接上秤 —— 秤会垮,船才是出路
  await dragDesign(page, SUB.heavyHome, { x: SUB.scalePos.x, y: SUB.scalePos.y - 40 });
  expect(await events(page)).toContain('choice:made:scale');
  expect(await events(page)).not.toContain('scene:enter:reward');

  /* ② 上船 ③ 刻痕 ④ 六块石头 → level:complete / card:unlocked → session:exit */
});

坐标直接复用 packages/templates/src/layout.ts 的常量(SUB.heavyHome 等)—— 布局改了,测试跟着改一处。

② autoplay 与持久化:?autoplay=1 全流程 → 自动回大厅 → 断言 collected-count → 刷新页面仍是 1 → 图鉴里有卡片。这条用例同时验证了无头直达、进度落盘与 IndexedDB。

③ 行为语义断言(最见功力的一组):

  • 后羿:「把最后一个也射了,天黑之后太阳会自己回来(不会卡死)」;
  • 夸父:手动操作到河边时断言尚未通关,喝水后继续;
  • 愚公:三条里程碑按序各出现一次(beat:enter:milestone:...);
  • 太公:真实等满 45 秒 + 诱惑延后 5 秒,59.5 秒不卡死;
  • 大禹:三段河道 + 三次「三过家门」都不卡。

这些断言针对的正是「删掉暂停逻辑测试也会绿」的盲区 —— 所以先把里程碑事件补上,再对事件下断言。

④ 双语与慢设备:

  • smoke.spec.ts 末尾的循环用例枚举内容目录全部 id × 中英两种语言,每个典故在两种语言下都要能载入并过 schema(新增典故自动纳入);
  • slow-device.spec.ts 用 CDP 模拟 4 倍 CPU 降速,验证「慢设备按住奔跑」等墙钟相关逻辑。

慢设备用例的由头

夸父的长按玩法最初按帧 delta 累加进度,CI 与慢手机上「按住 3.4 秒到不了 45%」—— Phaser 在低帧率时会钳制 delta。修复是改按墙钟计时(第 7 章),并附上慢设备用例防止回归。

13.7 CI:三班岗

.github/workflows/ci.yml 在每次 push / PR 上跑三个 job:

check   Node 24 → pnpm install --frozen-lockfile
        → typecheck → lint → test → content:check → build(含体积守卫)

e2e     安装 chromium → pnpm e2e(开发服务器)
        → pnpm e2e:dist(生产产物)
        → 失败时上传 test-results(截图 + trace,保留 7 天)

audit   pnpm audit:i18n → 上传 .scratch/i18n-audit(保留 14 天)
        仅主干与手动触发(PR 上跑太慢,约 6 分钟)

这段 CI 的历史值得引用原文注释:

# 每次推送与 PR 都跑。
# 之前的 release.yml 只在打 tag 时跑,而且**不包括端到端** ——
# 也就是说玩法被改坏了也要等到发版那一刻才会发现。

同一次审查还修了另外两处 CI 盲区:失败时上传的是不存在的 playwright-report 目录(reporter 是 list),已改为 test-results;双语巡检从未自动跑过,已加入主干流水线。

13.8 完成定义

一次改动的收尾流程:

pnpm verify        # 六连跑必须全绿
pnpm e2e           # 交互有改动时
pnpm audit:i18n    # 文案/版面/语言有改动时

三条经验规则:

  1. 改玩法必跑 e2e;只改文案可以只跑 verify + 相关单测;
  2. 新用例要能失败:写完先故意破坏被测行为,确认用例变红(防「永远绿的断言」);
  3. 报错留证据:CI 失败自动带截图与 trace;本地失败看 test-results/ 与 window.__chags.lastError。

13.9 小结

  • 六层防线各司其职:类型、lint 禁令、单测、内容校验、体积守卫、端到端 + 巡检;
  • 一致性(id 四处对账)、可复现(时钟注入 + lint 禁令)、可观测(事件协议)贯穿所有测试;
  • 端到端的价值在语义断言(里程碑顺序、不能卡死、刷新还在),而不只是「能跑通」;
  • pnpm verify 全绿是「完成」的定义,CI 三班岗保证主干永远可用。

下一章:构建、打包与分发 —— 同一份站点产物如何变成一个 3.3 MB 的 Windows exe 与一个安卓 apk。