diff --git a/.planning/phases/06-unit-tests/06-COVERAGE-MAP.md b/.planning/phases/06-unit-tests/06-COVERAGE-MAP.md index 9349963..e7e6586 100644 --- a/.planning/phases/06-unit-tests/06-COVERAGE-MAP.md +++ b/.planning/phases/06-unit-tests/06-COVERAGE-MAP.md @@ -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`。 diff --git a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md index 9651bca..8b4af96 100644 --- a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md +++ b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md @@ -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) | 中 | diff --git a/packages-user/data-common/src/replay/sandbox.test.ts b/packages-user/data-common/src/replay/sandbox.test.ts new file mode 100644 index 0000000..816f0c4 --- /dev/null +++ b/packages-user/data-common/src/replay/sandbox.test.ts @@ -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, + notExecuted?: () => Promise +): 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 { + for (let index = 0; index < 1000 && !sandbox.ended; index++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +// 生成一个可外部兑现的异步结果 +function createDeferred() { + return Promise.withResolvers(); +} + +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(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); + }); +}); diff --git a/packages-user/data-common/src/replay/system.test.ts b/packages-user/data-common/src/replay/system.test.ts new file mode 100644 index 0000000..d614333 --- /dev/null +++ b/packages-user/data-common/src/replay/system.test.ts @@ -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 | undefined> = []; + return { + calls, + reseter: { + reset: (save?: Map) => { + 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([['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(); + }); +});