test(06-04): cover ReplaySystem and ReplaySandbox pipeline (stage 3)

- system.test.ts:registerCommand/getCommand、重复注册 163、record + onRecordCommand、沙箱创建/reset、释放与实例独立性
- sandbox.test.ts:step/onStep、失败即停 158、157、notExecuted 175、156、startIndex、play/pause/resume/stop、setSpeed
- 06-COVERAGE-MAP.md 追加 06-04 小节(148–163、175)
- 06-TEST-FINDINGS.md 登记 #06-04-1..4
This commit is contained in:
unanmed 2026-09-14 15:21:49 +08:00
parent 8832159f8c
commit cb9c90abc1
4 changed files with 621 additions and 0 deletions

View File

@ -26,6 +26,23 @@
| 96 | enemy/enemy.ts | `warns 96 and keeps the existing special on a duplicate code` | 06-03 |
| 117 | enemy/manager.ts | `warns 117 on a repeat compareWith and refreshes the dirty set` | 06-03 |
| 118 | enemy/manager.ts | `warns 118 and marks dirty when no comparer is attached` | 06-03 |
| 148 | replay/array.ts | `warns code 148 for an unknown param type` | 06-04 |
| 149 | replay/array.ts | `warns code 149 for an illegal expand multiplier` | 06-04 |
| 150 | replay/array.ts | `warns code 150 when the command array is full` | 06-04 |
| 151 | replay/array.ts | `warns code 151 for an out-of-range boolean byte` | 06-04 |
| 152 | replay/array.ts | `warns code 152 for an out-of-range bigint` | 06-04 |
| 153 | replay/array.ts | `warns code 153 when a command exceeds 255 params` | 06-04 |
| 154 | replay/array.ts | `warns code 154 when narrowing a command above 255` | 06-04 |
| 155 | replay/array.ts | `warns code 155 when reading an expired stream` | 06-04 |
| 156 | replay/sandbox.ts | `warns code 156 when the read stream expired`、`warns code 156 when playing with an expired stream` | 06-04 |
| 157 | replay/sandbox.ts | `warns code 157 for an unknown command` | 06-04 |
| 158 | replay/sandbox.ts | `warns code 158 and stops when a command returns false` | 06-04 |
| 159 | replay/func.ts | `warns code 159 when beginning during another collection` | 06-04 |
| 160 | replay/func.ts | `warns code 160 when ending outside a collection` | 06-04 |
| 161 | replay/func.ts | `collects nested decorated calls and warns code 161` | 06-04 |
| 162 | replay/func.ts | `warns code 162 when the detail code is unknown` | 06-04 |
| 163 | replay/system.ts | `warns code 163 on a duplicate registration and keeps the original` | 06-04 |
| 175 | replay/sandbox.ts | `warns code 175 when notExecuted fails` | 06-04 |
## 06-01 战斗系统(packages-user/data-system/src/combat)
@ -64,3 +81,23 @@ D-30:排除一切名称含 legacy 的接口/方法(`fromLegacyEnemy`/`addPre
故仅覆盖「注册/重复注册不报错」的最小正常用例。
一条疑似缺陷 `#06-03-1`(`createEnemy`/`createEnemyById` 未走复用映射)按 D-05 以 `it.skip`
的正确预期用例登记,详见 `06-TEST-FINDINGS.md`。
## 06-04 录像系统(packages-user/data-common/src/replay)
模块归属:148 / 149 / 150 / 151 / 152 / 153 / 154 / 155 → `replay/array.ts`(编码、数组扩容与读流);
156 / 157 / 158 / 175 → `replay/sandbox.ts`(播放器步进、收尾与读取流校验);
159 / 160 / 161 / 162 → `replay/func.ts`(播放安全收集装饰器);
163 → `replay/system.ts`(重复注册)。
阶段 1(构件级)完成 `ReplayArray` 单类型编解码与 `add`/`insert`/`delete`/`set`/`get` 单次读回,
并观测 148/151/152/153;
阶段 2(组合/流水线)完成读流/缓冲区组合、位宽切换与扩容,观测 149/150/154/155,并覆盖 `func.ts`
安全收集生命周期(159/160/161/162);
阶段 3(完整/集成)完成 `ReplaySystem` 注册/录制/沙箱生命周期(163)与 `ReplaySandbox` 播放控制
(156/157/158/175),含 play/pause/resume/stop 与失败即停。
D-32:不测 `ReplayArray.saveState`/`loadState`,存读档往返归 06-09。
D-40:不做完整录像播放与二次录制比对,`error 2001–2008` 归 06-07,本计划未触及。
四条编解码/编辑缺陷 `#06-04-1`(int64 解码乘数)、`#06-04-2`(多字节 bigint 编码)、
`#06-04-3`(delete 索引回退)、`#06-04-4`(insert 参数位移方向)按 D-05 以 `it.skip`
的正确预期用例登记,详见 `06-TEST-FINDINGS.md`。

View File

@ -49,3 +49,17 @@ D-30:`registerSpecial` 的覆盖语义只能经被排除的 legacy 转换路
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `manager.ts` `EnemyManager.createEnemy` / `createEnemyById` | 注册复用映射后,按复用 code/id 创建怪物返回 `null` | `addPrefab(code=1, id='slime')` → `reusePrefab(1, 100, 'slime-reuse')` → `createEnemy(100)` 返回 `null`,而 `getPrefab(100)` 能解析到来源模板 | `createEnemy`/`createEnemyById` 直接 `prefabByCode.get(code)`/`prefabById.get(id)`,未像 `getPrefab`/`getPrefabById`/`internalGetPrefab` 那样先经 `reuseByCode`/`reuseById` 解析 | 复用映射(面朝方向图块复用同一模板)对应的 code/id 无法生成怪物;当前生产代码尚无调用方,属潜在功能缺口 | 在 `createEnemy`/`createEnemyById` 中改用 `internalGetPrefab`(或先查复用映射再取模板) | `manager.test.ts` `creates enemies for reused codes and ids through the reuse mapping`(#06-03-1) | 中 |
## #06-04 录像系统(packages-user/data-common/src/replay)
本计划按 D-43 三阶段(构件 → 组合/流水线 → 完整/集成)执行,发现 4 处疑似 bug
(`#06-04-1..4`),均按 D-05 以正确预期的 `it.skip` 用例登记;`pnpm test:ci` 全绿
(36 文件 / 347 通过 / 9 跳过,其中 4 条为本计划新增 skip)。D-32:不测存读档;
D-40:不做完整播放/二次录制(归 06-07),`error 2001–2008` 未触及。
| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `array.ts` `ReplayArray.get`(int64 解码) | int64 参数写入后读回值与写入值不同 | `array.add(0, [2147483648])` → `array.get(0).params` 得到 `[2147483647]`,应为 `[2147483648]` | `decodeParam` 的 type 4 分支为 `value = low + high * 2147483647`,乘数应为 `2^31 = 2147483648` | 所有超出 int32 范围(int64 位宽)的录像参数读回值偏移 `high`;二次录制比对会误判不一致 | 将乘数改为 `2147483648` | `array.test.ts` `round-trips int64 values above the int32 range`(#06-04-1) | 中 |
| `array.ts` `ReplayArray`(bigint 编码) | 多字节 bigint 编码只保留最低字节,其余写 0,读回值远小于写入值 | `array.add(0, [0x0102030405060708n])` → `array.get(0).params` 得到 `[8n]`,应为 `[0x0102030405060708n]` | `normalizeParam` 编码循环 `base = param - total; remain = base % 256n` 未按字节右移,`total` 仅累积低位,导致 i≥1 时 `remain` 恒为 0;解码又用 `getInt8` 读无符号字节 | 任何需要 >1 字节的 bigint 参数无法正确往返 | 编码改为按位取字节(如 `(param >> (8n * BigInt(i))) % 256n`),解码改用无符号字节 | `array.test.ts` `round-trips a multi-byte bigint`(#06-04-2) | 中 |
| `array.ts` `ReplayArray.delete` | 删除中间步骤后索引数组未按删除位置回退,后续步骤读到错误参数 | `add(1,[10])`、`add(2,[20])`、`add(3,[30])` → `delete(1)` → `get(1).params` 期望 `[30]`,实际 `[false]` | 回退循环 `for (let i = paramStart; i < this.length; i++) this.indexArray[i] -= paramLength` 以 `paramStart`(参数字节偏移)当作命令索引起点,应从 `index` 起;且 `indexArray[index+1]` 对末步取到未初始化 0 | 删除任意非首步都会破坏其后步骤的参数读取;录像编辑不可靠 | 回退从 `index` 起(`for (let i = index; i < this.length; i++)`),并以 `paramUsed` 或哨兵替代 `indexArray[length]` 表示末步参数终点 | `array.test.ts` `deletes a middle step and shifts later param indexes`(#06-04-3) | 中 |
| `array.ts` `ReplayArray.insert` | 已有参数时插入步骤会丢失插入点之后的参数 | `add(1,[10])`、`add(3,[30])` → `insert(1, 2, [20])` → 读流第三条参数期望 `[30]`,实际 `[false]` | `this.paramArray.copyWithin(paramStart, paramStart + length)` 位移方向相反,应把 `[paramStart, …)` 移到 `paramStart + length` | 带参数的录像插入功能不可用,后续步骤参数错位 | 改为 `this.paramArray.copyWithin(paramStart + length, paramStart)` | `array.test.ts` `reads the new order after inserting a step`(#06-04-4) | 中 |

View File

@ -0,0 +1,410 @@
// 测试 ReplaySandbox 播放器的步进、收尾与播放控制
import { logger } from '@motajs/common';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { ReplaySystem } from './system';
import { IReplayCommand, IReplaySandbox, IReplayStepHandler } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
});
interface IManualReplaySandbox extends IReplaySandbox {
playing: boolean;
pausing: boolean;
}
// 构造一个可注入执行与收尾行为的录像命令
function createCommand(
execute: (step: IReplayStepHandler) => Promise<boolean>,
notExecuted?: () => Promise<boolean>
): IReplayCommand {
return notExecuted ? { execute, notExecuted } : { execute };
}
// 用录像系统构造一个可手动驱动的录像沙箱
function createSandbox(system: ReplaySystem): IManualReplaySandbox {
return system.createReplaySandbox({
route: system.route,
reseter: { reset: () => {} }
}) as IManualReplaySandbox;
}
// 将沙箱置为手动步进的播放状态
function start(sandbox: IManualReplaySandbox): void {
sandbox.playing = true;
sandbox.pausing = false;
}
// 有界等待播放结束,避免不终止的播放循环挂起测试
async function waitForEnded(sandbox: IReplaySandbox): Promise<void> {
for (let index = 0; index < 1000 && !sandbox.ended; index++) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
}
}
// 生成一个可外部兑现的异步结果
function createDeferred() {
return Promise.withResolvers<boolean>();
}
describe('ReplaySandbox stepping', () => {
// 验证 step 执行下一步、触发 onStep 钩子并返回 true
it('steps one command and dispatches the step hook', async () => {
const executed: number[] = [];
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async step => {
executed.push(step.index);
return true;
})
);
system.record(1, 5);
const sandbox = createSandbox(system);
const stepped: IReplayStepHandler[] = [];
sandbox
.addHook({
onStep: async step => {
stepped.push(step);
}
})
.load();
start(sandbox);
await expect(sandbox.step()).resolves.toBe(true);
expect(executed).toEqual([1]);
expect(stepped).toHaveLength(1);
expect(stepped[0].command).toBe(1);
expect(sandbox.getReplayed()).toBe(1);
});
// 验证执行失败的命令触发告警码 158 并停止后续步骤
it('warns code 158 and stops when a command returns false', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const executed: number[] = [];
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async () => {
executed.push(1);
return false;
})
);
system.registerCommand(
2,
createCommand(async () => {
executed.push(2);
return true;
})
);
system.record(1);
system.record(2);
const sandbox = createSandbox(system);
start(sandbox);
await expect(sandbox.step()).resolves.toBe(false);
expect(executed).toEqual([1]);
expect(warn).toHaveBeenCalledWith(158, '1', '[]');
expect(sandbox.getReplayed()).toBe(1);
});
// 验证未知指令触发告警码 157 并返回 false
it('warns code 157 for an unknown command', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const system = new ReplaySystem();
system.record(9);
const sandbox = createSandbox(system);
start(sandbox);
await expect(sandbox.step()).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(157, '9');
});
// 验证切换指令时先成功收尾上一步再执行下一步
it('finalizes the previous command before executing a different one', async () => {
const order: string[] = [];
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(
async () => {
order.push('exec1');
return true;
},
async () => {
order.push('fin1');
return true;
}
)
);
system.registerCommand(
2,
createCommand(async () => {
order.push('exec2');
return true;
})
);
system.record(1);
system.record(2);
const sandbox = createSandbox(system);
start(sandbox);
await expect(sandbox.step()).resolves.toBe(true);
await expect(sandbox.step()).resolves.toBe(true);
expect(order).toEqual(['exec1', 'fin1', 'exec2']);
});
// 验证 notExecuted 收尾失败触发告警码 175 并停止
it('warns code 175 when notExecuted fails', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(
async () => true,
async () => false
)
);
system.registerCommand(
2,
createCommand(async () => true)
);
system.record(1);
system.record(2);
const sandbox = createSandbox(system);
start(sandbox);
await expect(sandbox.step()).resolves.toBe(true);
await expect(sandbox.step()).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(175, '1');
});
// 验证读取流过期时触发告警码 156 并返回 false
it('warns code 156 when the read stream expired', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async () => true)
);
system.record(1);
const sandbox = createSandbox(system);
start(sandbox);
system.route.add(1, []);
await expect(sandbox.step()).resolves.toBe(false);
expect(warn).toHaveBeenCalledWith(156);
});
// 验证沙箱从指定起始索引开始读取并正确报告已播放步数
it('starts from the configured index and counts replayed steps', async () => {
const executed: number[] = [];
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async step => {
executed.push(step.index);
return true;
})
);
system.record(1);
system.record(1);
system.record(1);
const sandbox = system.createReplaySandbox({
route: system.route,
reseter: { reset: () => {} },
startIndex: 2
}) as IManualReplaySandbox;
start(sandbox);
expect(sandbox.getReplayed()).toBe(2);
await expect(sandbox.step()).resolves.toBe(true);
expect(executed).toEqual([3]);
});
});
describe('ReplaySandbox playback control', () => {
// 验证 play 依次执行全部步骤并在结束时置位 ended
it('plays through every step and marks the sandbox ended', async () => {
const order: number[] = [];
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async step => {
order.push(step.index);
return true;
})
);
system.record(1);
system.record(1);
const sandbox = createSandbox(system);
const startHook = vi.fn();
sandbox.addHook({ onStartReplay: startHook }).load();
sandbox.play();
await waitForEnded(sandbox);
expect(startHook).toHaveBeenCalledTimes(1);
expect(sandbox.playing).toBe(true);
expect(sandbox.ended).toBe(true);
expect(order).toEqual([1, 2]);
});
// 验证已在播放时再次 play 不重复触发 onStartReplay
it('does not restart an already playing sandbox', async () => {
const system = new ReplaySystem();
const deferred = createDeferred();
system.registerCommand(
1,
createCommand(() => deferred.promise)
);
system.record(1);
const sandbox = createSandbox(system);
const startHook = vi.fn();
sandbox.addHook({ onStartReplay: startHook }).load();
sandbox.play();
sandbox.play();
deferred.resolve(true);
await waitForEnded(sandbox);
expect(startHook).toHaveBeenCalledTimes(1);
});
// 验证播放结束后再次 play 是空操作
it('ignores play after the replay ended', async () => {
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async () => true)
);
system.record(1);
const sandbox = createSandbox(system);
const startHook = vi.fn();
sandbox.addHook({ onStartReplay: startHook }).load();
sandbox.play();
await waitForEnded(sandbox);
sandbox.play();
expect(sandbox.ended).toBe(true);
expect(startHook).toHaveBeenCalledTimes(1);
});
// 验证 pause 在暂停点兑现且 resume 从暂停点继续执行
it('pauses at the next step boundary and resumes', async () => {
const order: number[] = [];
const first = createDeferred();
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(step => {
order.push(step.index);
return step.index === 1 ? first.promise : Promise.resolve(true);
})
);
system.record(1);
system.record(1);
const sandbox = createSandbox(system);
const pauseHook = vi.fn();
const resumeHook = vi.fn();
sandbox
.addHook({
onPauseReplay: pauseHook,
onResumeReplay: resumeHook
})
.load();
sandbox.play();
const paused = sandbox.pause();
first.resolve(true);
await paused;
expect(pauseHook).toHaveBeenCalledTimes(1);
expect(sandbox.pausing).toBe(true);
expect(order).toEqual([1]);
sandbox.resume();
await waitForEnded(sandbox);
expect(resumeHook).toHaveBeenCalledTimes(1);
expect(sandbox.ended).toBe(true);
expect(order).toEqual([1, 2]);
});
// 验证 stop 先暂停播放并触发 onStopReplay
it('pauses and dispatches onStopReplay on stop', async () => {
const first = createDeferred();
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(() => first.promise)
);
system.record(1);
const sandbox = createSandbox(system);
const pauseHook = vi.fn();
const stopHook = vi.fn();
sandbox
.addHook({
onPauseReplay: pauseHook,
onStopReplay: stopHook
})
.load();
sandbox.play();
const stopped = sandbox.stop();
first.resolve(true);
await stopped;
expect(pauseHook).toHaveBeenCalledTimes(1);
expect(stopHook).toHaveBeenCalledTimes(1);
expect(sandbox.pausing).toBe(true);
});
// 验证 play 遇到过期读取流时告警码 156 且不进入播放
it('warns code 156 when playing with an expired stream', async () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const system = new ReplaySystem();
system.registerCommand(
1,
createCommand(async () => true)
);
system.record(1);
const sandbox = createSandbox(system);
system.route.add(1, []);
sandbox.play();
await new Promise<void>(resolve => setTimeout(resolve, 0));
expect(warn).toHaveBeenCalledWith(156);
expect(sandbox.ended).toBe(false);
});
// 验证 setSpeed 触发 onSpeedSet 并更新播放倍率
it('dispatches onSpeedSet when the speed changes', () => {
const system = new ReplaySystem();
const sandbox = createSandbox(system);
const speedHook = vi.fn();
sandbox.addHook({ onSpeedSet: speedHook }).load();
sandbox.setSpeed(2);
expect(sandbox.speed).toBe(2);
expect(speedHook).toHaveBeenCalledWith(2);
});
});

View File

@ -0,0 +1,160 @@
// 测试 ReplaySystem 的指令注册、录像录制与沙箱生命周期
import { logger } from '@motajs/common';
import { afterAll, describe, expect, it, vi } from 'vitest';
import { ReplaySystem } from './system';
import {
IReplayCommand,
IReplaySandbox,
ReplayCommandWidth,
ReplayParamValue
} from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
});
afterAll(() => {
vi.unstubAllGlobals();
});
// 构造一个结果固定的录像命令
function createCommand(result: boolean = true): IReplayCommand {
return { execute: async () => result };
}
// 构造一个记录 reset 入参的状态重置对象
function createReseter() {
const calls: Array<Map<string, unknown> | undefined> = [];
return {
calls,
reseter: {
reset: (save?: Map<string, unknown>) => {
calls.push(save);
}
}
};
}
describe('ReplaySystem registration and recording', () => {
// 验证构造出的录像操作器为默认 uint8 位宽且初始为空
it('builds an empty uint8 route', () => {
const system = new ReplaySystem();
expect(system.route.commandWidth).toBe(ReplayCommandWidth.Uint8);
expect(system.route.length).toBe(0);
});
// 验证注册后可取回命令且未知码返回 null
it('registers a command and returns null for an unknown code', () => {
const system = new ReplaySystem();
const command = createCommand();
system.registerCommand(7, command);
expect(system.getCommand(7)).toBe(command);
expect(system.getCommand(99)).toBeNull();
});
// 验证重复注册触发告警码 163 且保留原命令
it('warns code 163 on a duplicate registration and keeps the original', () => {
const system = new ReplaySystem();
const first = createCommand(true);
const second = createCommand(false);
system.registerCommand(1, first);
const { info } = logger.catch(() => system.registerCommand(1, second));
expect(info.map(v => v.code)).toContain(163);
expect(system.getCommand(1)).toBe(first);
});
// 验证 record 追加录像步并触发 onRecordCommand 钩子
it('appends a replay step and dispatches the record hook', () => {
const system = new ReplaySystem();
const recorded: Array<
readonly [number, number, readonly ReplayParamValue[]]
> = [];
system
.addHook({
onRecordCommand: (code, index, params) => {
recorded.push([code, index, params]);
}
})
.load();
system.record(5, 1, true);
expect(system.route.length).toBe(1);
expect(system.route.get(0)).toEqual({
command: 5,
params: [1, true],
index: 0
});
expect(recorded).toEqual([[5, 1, [1, true]]]);
});
// 验证每个 ReplaySystem 实例的注册与录像相互独立
it('keeps commands and routes independent between instances', () => {
const first = new ReplaySystem();
const second = new ReplaySystem();
first.registerCommand(1, createCommand());
first.record(1);
expect(second.getCommand(1)).toBeNull();
expect(second.route.length).toBe(0);
expect(first.route.length).toBe(1);
});
});
describe('ReplaySystem sandbox lifecycle', () => {
// 验证创建沙箱时用存档重置状态、保存 sandbox 并触发 onCreateSandbox
it('creates a sandbox, resets state and dispatches onCreateSandbox', () => {
const system = new ReplaySystem();
const { calls, reseter } = createReseter();
const created: IReplaySandbox[] = [];
system
.addHook({
onCreateSandbox: sandbox => {
created.push(sandbox);
}
})
.load();
const save = new Map<string, unknown>([['hp', 10]]);
const sandbox = system.createReplaySandbox({
route: system.route,
reseter,
save
});
expect(calls).toEqual([save]);
expect(system.sandbox).toBe(sandbox);
expect(created).toEqual([sandbox]);
});
// 验证释放沙箱时停止播放并清空当前沙箱引用
it('stops and clears the active sandbox on release', () => {
const system = new ReplaySystem();
const { reseter } = createReseter();
const sandbox = system.createReplaySandbox({
route: system.route,
reseter
});
const stop = vi.spyOn(sandbox, 'stop').mockResolvedValue(undefined);
system.releaseSandbox();
expect(stop).toHaveBeenCalledTimes(1);
expect(system.sandbox).toBeNull();
});
// 验证没有活跃沙箱时释放操作安全地保持为空
it('releases safely when no sandbox is active', () => {
const system = new ReplaySystem();
system.releaseSandbox();
expect(system.sandbox).toBeNull();
});
});