第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 的检查项:
- 内容 schema 本身由
allusions/index.ts在 import 时强制(能不通过就根本跑不到这里); - 每段关卡脚本必须符合声明的模板(查
templateSchemas注册表):
✗ substitution 脚本 stones: 用这些石头凑不出重物的重量(容差内无解),关卡不可通关
- 关键抉择的
beatId必须在故事里、必须有 transfer 题; - 每部典故打印一行概要(实测输出):
· 大禹治水 (dayu)
· 传说 / 2 幕故事 / 3 道复习题 / 2 段玩法:tap → trace
- 翻译体检:缺译文、结构不一致、数组长度不一致、合并后超 schema、合并后关卡脚本重新过模板 schema、CJK 泄漏扫描;
- 有任何一项失败:
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 # 文案/版面/语言有改动时
三条经验规则:
- 改玩法必跑 e2e;只改文案可以只跑
verify+ 相关单测; - 新用例要能失败:写完先故意破坏被测行为,确认用例变红(防「永远绿的断言」);
- 报错留证据:CI 失败自动带截图与 trace;本地失败看
test-results/与window.__chags.lastError。
13.9 小结
- 六层防线各司其职:类型、lint 禁令、单测、内容校验、体积守卫、端到端 + 巡检;
- 一致性(id 四处对账)、可复现(时钟注入 + lint 禁令)、可观测(事件协议)贯穿所有测试;
- 端到端的价值在语义断言(里程碑顺序、不能卡死、刷新还在),而不只是「能跑通」;
pnpm verify全绿是「完成」的定义,CI 三班岗保证主干永远可用。
下一章:构建、打包与分发 —— 同一份站点产物如何变成一个 3.3 MB 的 Windows exe 与一个安卓 apk。