上一节把合成规则写成了纯函数、用单元测试锁住了边界与不变量。但单元测试管不到「整条链路」:玩家点一下屏幕、方块飞过去、合成、写存档、切后台再切回来——这一连串动作跨越输入、渲染、存档好几个子系统,任何一处出错,单测都看不见。这一节把整条链路变成可自动跑的断言。
站内 测试专题 里有一套通用的端到端测试方法论;本节不重复那套框架讨论,只讲小游戏这个形态下最实用的一种端到端手段:录制 → 回放 → 终局摘要比对。
为什么「回放 + 摘要」比截图更值钱
最直觉的端到端测试是「跑一遍、截图、人眼比对」。问题是截图既脆又钝:换个字体、改个颜色就假红,而一个数值算错却可能截图看不出。回放换个思路:把玩家的操作记成一串带时间戳的输入,重放时只关心终局状态的一行摘要。只要摘要一致,就证明整条链路逐位可复现;一旦不一致,摘要里的每一段(时钟、触发数、接受数、价值、棋盘)都能告诉你是哪一环变了。
这套做法的前提是确定性:同样的输入必得同样的输出。这也是为什么本套的 core/ 里禁用 Math.random()——随机数一律来自配置里的 seed,才能被回放。
录制:整数时间戳的输入序列
下面这段是 P 层(内核层)产物 core/src/e15/replay.ts:一份录制是「一串带整数毫秒时间戳的合成指令」,回放按固定步长推进时钟,事件到点才执行。关键性质是——同一份录制,无论时钟怎么切片,终局摘要逐位相同。它复用上一节的 spec.ts 棋盘逻辑,全整数、可播种、无 Math.random()。
// file: core/src/e15/replay.ts
// tier: P
// verify: node --test 'core/test/e15_*.test.ts'
//
// 输入序列录制 → 回放 → 终局摘要比对。录制是一串带「整数毫秒时间戳」的合成指令;
// 回放按固定步长推进时钟,事件到点才执行。同一份录制,无论时钟怎么切片,终局摘要逐位相同——
// 这正是「一份逻辑、两种宿主」在测试层的落地:Node 与浏览器读同一份录制、跑同一段逻辑,必得同一行摘要。
// 复用 spec.ts 的棋盘逻辑;全整数、可播种、无 Math.random()。
import { ediv, mergeAt, totalValue, occupied, digest, initialBoard, generateOps } from "./spec.ts";
import type { SpecConfig, Board } from "./spec.ts";
export interface RecEvent { readonly tMs: number; readonly i: number; readonly j: number }
export interface Recording { readonly seed: number; readonly spanMs: number; readonly events: RecEvent[] }
export interface ReplayState {
board: Board; clockMs: number; cursor: number;
accepted: number; rejected: number; reasonCounts: Record<string, number>;
}
export function newReplay(cfg: SpecConfig): ReplayState {
return { board: initialBoard(cfg), clockMs: 0, cursor: 0, accepted: 0, rejected: 0, reasonCounts: {} };
}
/** 从 seed 造一份录制:count 条指令,时间戳均匀铺在 (0, spanMs] 上,整数毫秒、升序。 */
export function makeRecording(seed: number, count: number, spanMs: number, cfg: SpecConfig): Recording {
const seq = generateOps(seed, count, cfg);
const events: RecEvent[] = seq.ops.map((op, k) => ({ tMs: ediv((k + 1) * spanMs, count), i: op.i, j: op.j }));
return { seed, spanMs, events };
}
/** 推进 dtMs(整数):触发所有 tMs ≤ 新时钟 的事件,返回本次触发数。 */
export function tick(st: ReplayState, dtMs: number, rec: Recording, cfg: SpecConfig): number {
st.clockMs += dtMs;
let fired = 0;
while (st.cursor < rec.events.length && rec.events[st.cursor].tMs <= st.clockMs) {
const ev = rec.events[st.cursor];
const r = mergeAt(st.board, ev.i, ev.j, cfg);
st.reasonCounts[r.reason] = (st.reasonCounts[r.reason] ?? 0) + 1;
if (r.ok) { st.board = r.board; st.accepted += 1; } else st.rejected += 1;
st.cursor += 1; fired += 1;
}
return fired;
}
/** 跑完整段录制:按 stepMs 推进时钟到最后一个事件时间。stepMs 可覆盖,用于切片一致性测试。 */
export function runReplay(rec: Recording, cfg: SpecConfig, stepMs: number = cfg.stepMs): ReplayState {
const st = newReplay(cfg);
const last = rec.events.length ? rec.events[rec.events.length - 1].tMs : 0;
while (st.clockMs < last) tick(st, Math.min(stepMs, last - st.clockMs), rec, cfg);
return st;
}
/** 终局摘要:一行、逐位可比。Node 与浏览器读同一份录制必须得到这一行。 */
export function summary(st: ReplayState, cfg: SpecConfig): string {
return ["clock=" + st.clockMs, "fired=" + st.cursor, "acc=" + st.accepted, "rej=" + st.rejected,
"val=" + totalValue(st.board, cfg), "occ=" + occupied(st.board), "board=" + digest(st.board)].join("|");
}
录制参数放在 config/e15_replay.json(D 层,纯参数):seed + count + spanMs 决定录制的全部内容,stepMs 是默认时钟切片。时间戳均匀铺在 (0, spanMs] 上,是整数毫秒——这是回放能逐位复现的前提。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"count": 120,
"spanMs": 6000,
"stepMs": 50,
"note": "回放录制参数。录制由 seed + count + spanMs 确定性生成:时间戳均匀铺在 (0, spanMs],整数毫秒升序。Node 侧与浏览器侧读同一份参数、跑同一段 replay.ts,终局摘要必须逐位相同。"
}
Node 侧回放测试
下面这段是 P 层测试 core/test/e15_replay.test.ts:核心断言是切片一致——同一份录制,用 1ms / 17ms / 50ms 三种时钟切片回放,终局摘要必须逐位相同。这条性质成立,才谈得上「Node 与浏览器摘要一致」。
// file: core/test/e15_replay.test.ts
// tier: P
// verify: node --test 'core/test/e15_*.test.ts'
//
// 端到端回放的 Node 侧断言:同一份录制,无论时钟怎么切片,终局摘要逐位相同。
// 这条性质就是 W 层「浏览器回放摘要 == Node 侧摘要」的地基。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { makeRecording, runReplay, summary, newReplay, tick } from "../src/e15/replay.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e15_spec.json"), "utf8"));
const rc = JSON.parse(readFileSync(join(ROOT, "config", "e15_replay.json"), "utf8"));
const rec = makeRecording(rc.seed, rc.count, rc.spanMs, cfg);
test("录制生成:同参数两次生成事件逐条相同,时间戳升序", () => {
const a = makeRecording(rc.seed, rc.count, rc.spanMs, cfg);
const b = makeRecording(rc.seed, rc.count, rc.spanMs, cfg);
assert.deepEqual(a.events, b.events, "同参数应生成同一份录制");
assert.equal(a.events.length, rc.count);
for (let k = 1; k < a.events.length; k += 1) {
assert.ok(a.events[k].tMs >= a.events[k - 1].tMs, "时间戳应非降");
}
assert.equal(a.events[a.events.length - 1].tMs, rc.spanMs, "最后一个事件应落在 spanMs");
});
test("切片一致:stepMs=1 / 17 / 50 三种时钟切片终局摘要逐位相同", () => {
const s50 = summary(runReplay(rec, cfg, 50), cfg);
const s17 = summary(runReplay(rec, cfg, 17), cfg);
const s1 = summary(runReplay(rec, cfg, 1), cfg);
assert.equal(s17, s50, "17ms 切片应与 50ms 一致");
assert.equal(s1, s50, "1ms 切片应与 50ms 一致");
});
test("确定性:同录制两次回放摘要相同", () => {
assert.equal(summary(runReplay(rec, cfg), cfg), summary(runReplay(rec, cfg), cfg));
});
test("tick 语义:事件在时钟走到其时间戳时才触发", () => {
const st = newReplay(cfg);
assert.equal(tick(st, 39, rec, cfg), 0, "39ms 时第一个事件(tMs=50)还没到");
assert.equal(tick(st, 11, rec, cfg), 1, "补到 50ms 时第一个事件触发");
assert.equal(st.cursor, 1);
assert.equal(tick(st, 49, rec, cfg), 0, "再走 49ms 到 99ms,第二个事件(tMs=100)未到");
});
test("金标准摘要:固定录制的终局可跨宿主比对", () => {
const st = runReplay(rec, cfg, 50);
assert.equal(summary(st, cfg), "clock=6000|fired=120|acc=5|rej=115|val=12|occ=7|board=1,2,1,2,0,0,0,3,1,0,1,0");
});
W 层:在浏览器里回放同一份录制
光在 Node 里跑通还不够——真机上跑的是浏览器里的那份代码。下面这段是 W 层(网页层)构建脚本 web/build_e15.mjs:它复用 web/pack.mjs 的擦除逻辑,把 spec.ts + replay.ts 内联进单文件 HTML;构建期先在 Node 侧跑一遍回放,把摘要内联为 NODE_SUMMARY,供浏览器侧逐位对照。
#!/usr/bin/env node
// file: web/build_e15.mjs
// tier: W
// verify: node web/build_e15.mjs
// 把 core/src/e15/{spec,replay}.ts 打成单文件 HTML;复用 web/pack.mjs 的擦除逻辑,产物 web/dist/e15.html。
// 构建期先在 Node 侧跑一遍回放,把终局摘要内联为 NODE_SUMMARY,供浏览器侧逐位对照。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { makeRecording, runReplay, summary } from "../core/src/e15/replay.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e15_spec.json"), "utf8"));
const rc = JSON.parse(readFileSync(join(ROOT, "config", "e15_replay.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e15/spec.ts", "core/src/e15/replay.ts"]);
// Node 侧基准:同一份录制、同一段逻辑,这里算出的摘要会被内联进页面。
const nodeSummary = summary(runReplay(makeRecording(rc.seed, rc.count, rc.spanMs, cfg), cfg), cfg);
const script = `const CFG = ${inlineConfig(cfg)};
const RC = ${inlineConfig(rc)};
const NODE_SUMMARY = ${JSON.stringify(nodeSummary)};
${js}
const REC = makeRecording(RC.seed, RC.count, RC.spanMs, CFG);
const st = runReplay(REC, CFG);
const pageSummary = summary(st, CFG);
document.getElementById("board").textContent = st.board.join(" ");
document.getElementById("psum").textContent = pageSummary;
document.getElementById("eq").textContent = String(pageSummary === NODE_SUMMARY);
globalThis.__pebble = { pageSummary, nodeSummary: NODE_SUMMARY, accepted: st.accepted, rejected: st.rejected,
clockMs: st.clockMs, ready: true, api: {
run() { const s = runReplay(REC, CFG); return { summary: summary(s, CFG), accepted: s.accepted, rejected: s.rejected }; },
slice(stepMs) { return summary(runReplay(REC, CFG, stepMs), CFG); },
} };
`;
const body = ` <h1>《合成小岛》· 回放与终局摘要原型</h1>
<div class="sub">回放来自 core/src/e15/replay.ts:读同一份录制,Node 与浏览器摘要须逐位相同</div>
<div class="card"><div class="k">终局棋盘(槽位等级)</div><div class="big" id="board" style="font-size:18px">-</div></div>
<div class="card"><div class="k">浏览器侧摘要</div><div class="big" id="psum" style="font-size:13px">-</div></div>
<div class="card row"><div><div class="k">与 Node 侧相同</div><div class="big" id="eq">-</div></div></div>`;
const bytes = writePage(join(ROOT, "web/dist/e15.html"), page({ title: "《合成小岛》回放与终局摘要原型 · e15", body, script }));
console.log(JSON.stringify({ html: "web/dist/e15.html", htmlBytes: bytes, bundleLines: js.split("\n").length, nodeSummary }, null, 2));
下面这段是 W 层验证脚本 web/verify_e15.mjs:Playwright 无头打开单文件 HTML,在浏览器里回放同一份录制,断言浏览器侧摘要 == Node 侧摘要逐位相同,并验证浏览器内不同切片摘要不变。playwright 用 createRequire(import.meta.url) 加载,因为 NODE_PATH 对 ESM 的 import 无效。
#!/usr/bin/env node
// file: web/verify_e15.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e15.mjs web/dist/e15.html
// W 层验收:Playwright 无头打开单文件 HTML,在浏览器里回放同一份录制序列,
// 断言「浏览器侧终局摘要 == Node 侧摘要」逐位相同,并验证不同时钟切片摘要不变,截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { readFileSync, mkdirSync } from "node:fs";
import assert from "node:assert/strict";
import { makeRecording, runReplay, summary } from "../core/src/e15/replay.ts";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e15.html")).href;
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e15_spec.json"), "utf8"));
const rc = JSON.parse(readFileSync(join(ROOT, "config", "e15_replay.json"), "utf8"));
// Node 侧基准:本脚本独立再算一遍(不读页面内联值),作为对照真源。
const nodeSummary = summary(runReplay(makeRecording(rc.seed, rc.count, rc.spanMs, cfg), cfg), cfg);
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const errors = [];
const page = await browser.newPage({ viewport: { width: 800, height: 620 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 10000 });
const got = await page.evaluate(() => ({
pageSummary: globalThis.__pebble.pageSummary,
inlinedNodeSummary: globalThis.__pebble.nodeSummary,
accepted: globalThis.__pebble.accepted, rejected: globalThis.__pebble.rejected, clockMs: globalThis.__pebble.clockMs,
rerun: globalThis.__pebble.api.run(),
slice17: globalThis.__pebble.api.slice(17), slice1: globalThis.__pebble.api.slice(1),
}));
await page.screenshot({ path: join(ROOT, "out/shots/e15_replay.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(got.pageSummary, nodeSummary, "浏览器侧回放摘要应与 Node 侧逐位相同");
assert.equal(got.inlinedNodeSummary, nodeSummary, "页面内联的 Node 基准应与脚本独立计算一致");
assert.equal(got.rerun.summary, nodeSummary, "浏览器内重跑应可复现");
assert.equal(got.slice17, nodeSummary, "浏览器内 17ms 切片摘要应不变");
assert.equal(got.slice1, nodeSummary, "浏览器内 1ms 切片摘要应不变");
assert.equal(got.accepted, 5, "接受合成数应为 5");
assert.equal(got.rejected, 115, "拒绝合成数应为 115");
console.log(JSON.stringify({
page: "web/dist/e15.html", nodeSummary, browserSummary: got.pageSummary,
inlinedNodeSummary: got.inlinedNodeSummary, identical: got.pageSummary === nodeSummary,
accepted: got.accepted, rejected: got.rejected, clockMs: got.clockMs,
slice17Matches: got.slice17 === nodeSummary, slice1Matches: got.slice1 === nodeSummary,
screenshot: "out/shots/e15_replay.png",
}, null, 2));
console.log("SMOKE-W15: PASS");
先构建、再验证,本机实跑输出如下(同机相对量级,绝对数值不可移植):
$ node --test 'core/test/e15_*.test.ts' # 5 条回放测试 + 9 条单测,pass 14 / fail 0
$ node web/build_e15.mjs
{ "html": "web/dist/e15.html", "htmlBytes": 9848, "bundleLines": 129,
"nodeSummary": "clock=6000|fired=120|acc=5|rej=115|val=12|occ=7|board=1,2,1,2,0,0,0,3,1,0,1,0" }
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e15.mjs web/dist/e15.html
{ "nodeSummary": "clock=6000|fired=120|acc=5|rej=115|val=12|occ=7|board=1,2,1,2,0,0,0,3,1,0,1,0",
"browserSummary": "clock=6000|fired=120|acc=5|rej=115|val=12|occ=7|board=1,2,1,2,0,0,0,3,1,0,1,0",
"identical": true, "accepted": 5, "rejected": 115,
"slice17Matches": true, "slice1Matches": true }
SMOKE-W15: PASS
两侧摘要逐位相同意味着什么
看 nodeSummary 与 browserSummary 那两行——字符完全一样:clock=6000|fired=120|acc=5|rej=115|val=12|occ=7|board=1,2,1,2,0,0,0,3,1,0,1,0。Node 里算的、Chromium 里算的,是同一行。这说明三件事同时成立:录制的 120 条指令在两侧逐条相同(fired=120)、每条指令的判定结果相同(acc=5 / rej=115)、终局棋盘逐位相同(board=...)。其中 slice17Matches 和 slice1Matches 两条额外断言说明:在浏览器里把时钟切成 17ms 或 1ms 的碎片,摘要依然不变——切片方式不影响结果。
这正是「一份逻辑、两种宿主」的可断言形态:只要抽象层没有泄漏宿主差异,同一段逻辑在两种宿主上就跑出同一行摘要;一旦哪天有人偷偷在某个分支里用了 Date.now() 或 Math.random(),这行摘要立刻对不上。回放测试的价值不在于「跑通」,而在于这行摘要是一份可以长期比对的契约。
小结与下一步
这一节把端到端链路变成了可自动跑的断言:录制是带整数时间戳的输入序列,回放按固定步长推进时钟,Node 与浏览器读同一份录制、跑同一段逻辑、比同一行摘要。但「摘要一致」只能证明逻辑一致,证明不了体验一致——同一段代码在 8G 旗舰机和 2G 入门机上,冷启动、帧率、内存表现可能天差地别。下一节就把「在哪些设备上测、每台测什么、风险怎么加权」摆成一张真机测试矩阵。
阅读导航:上一节:逻辑层单元测试 · 下一节:兼容性与真机测试矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。