diff --git a/packages-user/data-common/src/replay/array.test.ts b/packages-user/data-common/src/replay/array.test.ts index 6a82e1f..fc44457 100644 --- a/packages-user/data-common/src/replay/array.test.ts +++ b/packages-user/data-common/src/replay/array.test.ts @@ -277,3 +277,200 @@ describe('ReplayArray param codec', () => { expect(info.map(v => v.code)).toContain(153); }); }); + +describe('ReplayArray stream and buffer combination', () => { + // 验证 createReadStream 从起始索引顺序读回多步并在末尾返回 null + it('reads a sequence of steps through a read stream', () => { + const array = createArray(); + array.add(1, [10]); + array.add(2, [20]); + array.add(3, [30]); + + const stream = array.createReadStream(0); + expect(stream.index).toBe(0); + expect(stream.length).toBe(3); + expect(stream.read()).toEqual({ command: 1, params: [10], index: 1 }); + expect(stream.read()).toEqual({ command: 2, params: [20], index: 2 }); + expect(stream.read()).toEqual({ command: 3, params: [30], index: 3 }); + expect(stream.read()).toBeNull(); + }); + + // 验证 createReadStream 可从指定起始索引开始读取 + it('starts a read stream at the given index', () => { + const array = createArray(); + array.add(1, []); + array.add(2, []); + array.add(3, []); + + const stream = array.createReadStream(2); + expect(stream.read()).toEqual({ command: 3, params: [], index: 3 }); + expect(stream.read()).toBeNull(); + }); + + // 疑似 bug:insert 的参数缓冲区位移方向相反,已有参数时后续步骤读到错误参数,详见 06-TEST-FINDINGS.md #06-04-4,修复后取消 skip + it.skip('reads the new order after inserting a step', () => { + const array = createArray(); + array.add(1, [10]); + array.add(3, [30]); + array.insert(1, 2, [20]); + + const stream = array.createReadStream(0); + expect(stream.read()).toEqual({ command: 1, params: [10], index: 1 }); + expect(stream.read()).toEqual({ command: 2, params: [20], index: 2 }); + expect(stream.read()).toEqual({ command: 3, params: [30], index: 3 }); + }); + + // 验证 getCommandArray 与 getParamArray 暴露内部缓冲区的直接内容 + it('exposes the underlying command and param buffers', () => { + const array = createArray(); + array.add(3, [1]); + + const commands = new Uint8Array(array.getCommandArray()); + const params = new Uint8Array(array.getParamArray()); + expect(array.getCommandArray()).toBeInstanceOf(ArrayBuffer); + expect(commands[0]).toBe(1); + expect(commands[1]).toBe(3); + expect(params[0]).toBe(1); + expect(params[1]).toBe(1); + }); + + // 验证 setReplayArray 装载原始缓冲区并重建索引后可读回步骤 + it('loads raw buffers through setReplayArray and reads them back', () => { + const source = createArray(); + source.add(1, [10]); + source.add(2, [20]); + + const loaded = createArray(); + loaded.setReplayArray( + ReplayCommandWidth.Uint8, + source.getCommandArray().slice(0, 4), + source.getParamArray().slice(0, 4), + 2 + ); + + expect(loaded.length).toBe(2); + expect(loaded.get(0)).toEqual({ command: 1, params: [10], index: 0 }); + expect(loaded.get(1)).toEqual({ command: 2, params: [20], index: 1 }); + }); + + // 验证 rebuildIndexArray 幂等,重复重建后读回结果不变 + it('rebuilds the index array idempotently', () => { + const array = createArray(); + array.add(5, ['a', 7]); + array.add(6, []); + const before = array.get(1); + + array.rebuildIndexArray(); + + expect(array.get(1)).toEqual(before); + expect(array.get(0)).toEqual({ + command: 5, + params: ['a', 7], + index: 0 + }); + }); + + // 验证 setCommandWidth 加宽到 uint16 后指令与参数仍能读回 + it('re-encodes commands when widening to uint16', () => { + const array = createArray(); + array.add(1, [10]); + array.add(200, ['x']); + array.setCommandWidth(ReplayCommandWidth.Uint16); + + expect(array.commandWidth).toBe(ReplayCommandWidth.Uint16); + expect(array.get(0)).toEqual({ command: 1, params: [10], index: 0 }); + expect(array.get(1)).toEqual({ command: 200, params: ['x'], index: 1 }); + }); + + // 验证 setCommandWidth 可从 uint16 缩回 uint8 且不影响合法指令 + it('narrows commands back to uint8', () => { + const array = createArray({ + commandWidth: ReplayCommandWidth.Uint16 + }); + array.add(200, [7]); + array.setCommandWidth(ReplayCommandWidth.Uint8); + + expect(array.commandWidth).toBe(ReplayCommandWidth.Uint8); + expect(array.get(0)).toEqual({ command: 200, params: [7], index: 0 }); + }); +}); + +describe('ReplayArray expand and width warnings', () => { + // 验证扩容乘数小于 1 时触发告警码 149 并回退为默认倍率 + it('warns code 149 for an illegal expand multiplier', () => { + const { info } = logger.catch(() => + createArray({ + commandExpandMultiplier: 0, + paramExpandMultiplier: 0 + }) + ); + + expect(info.map(v => v.code)).toEqual([149, 149]); + }); + + // 验证指令数组达到上限后触发告警码 150 + it('warns code 150 when the command array is full', () => { + const array = createArray({ + initCommandLength: 10, + commandMaxLength: 10 + }); + + const { info } = logger.catch(() => { + array.add(1, []); + array.add(2, []); + array.add(3, []); + }); + + expect(info.map(v => v.code)).toContain(150); + }); + + // 验证从 uint16 缩回 uint8 时超出 255 的指令触发告警码 154 + it('warns code 154 when narrowing a command above 255', () => { + const array = createArray({ + commandWidth: ReplayCommandWidth.Uint16 + }); + array.add(300, []); + + const { info } = logger.catch(() => + array.setCommandWidth(ReplayCommandWidth.Uint8) + ); + + expect(info.map(v => v.code)).toContain(154); + expect(array.commandWidth).toBe(ReplayCommandWidth.Uint8); + }); + + // 验证录像被修改后读取流过期并触发告警码 155 + it('warns code 155 when reading an expired stream', () => { + const array = createArray(); + array.add(1, []); + const stream = array.createReadStream(0); + array.add(2, []); + + const { info } = logger.catch(() => stream.read()); + + expect(stream.expired).toBe(true); + expect(info.map(v => v.code)).toContain(155); + }); + + // 验证初始容量不足时自动扩容且扩容后仍能顺序读回每一步 + it('expands buffers and still reads every step back', () => { + const array = createArray({ + initCommandLength: 2, + initParamLength: 8 + }); + for (let i = 0; i < 15; i++) { + array.add(i, [i]); + } + + expect(array.length).toBe(15); + const stream = array.createReadStream(0); + for (let i = 0; i < 15; i++) { + expect(stream.read()).toEqual({ + command: i, + params: [i], + index: i + 1 + }); + } + expect(stream.read()).toBeNull(); + }); +}); diff --git a/packages-user/data-common/src/replay/func.test.ts b/packages-user/data-common/src/replay/func.test.ts new file mode 100644 index 0000000..ed66768 --- /dev/null +++ b/packages-user/data-common/src/replay/func.test.ts @@ -0,0 +1,142 @@ +// 测试 replay 播放安全收集装饰器的组合生命周期与其告警码 +import { logger } from '@motajs/common'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { + beginReplaySafetyCollection, + endReplaySafetyCollection, + ignoreReplay, + logReplaySafetyDetail, + shouldReplay +} from './func'; +import { ReplaySystem } from './system'; + +vi.hoisted(() => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +// 构造 shouldReplay/ignoreReplay 需要的类方法上下文 +function methodContext( + name: string +): ClassMethodDecoratorContext Return> { + return { name } as ClassMethodDecoratorContext< + This, + (this: This) => Return + >; +} + +describe('replay safety collection', () => { + // 验证 begin 后嵌套 decorated 调用被收集且 end 触发告警码 161 并输出简化详情 + it('collects nested decorated calls and warns code 161', () => { + interface Fixture { + inner(): void; + outer(): void; + } + const inner = shouldReplay('inner')(function ( + this: Fixture + ): void {}, methodContext('inner')); + const outer = shouldReplay('outer')(function (this: Fixture): void { + this.inner(); + }, methodContext('outer')); + const fixture: Fixture = { inner, outer }; + const system = new ReplaySystem(); + const group = vi.spyOn(console, 'group').mockImplementation(() => {}); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const { info } = logger.catch(() => { + beginReplaySafetyCollection(system); + fixture.outer(); + fixture.inner(); + endReplaySafetyCollection(); + }); + + const detail = info.find(v => v.code === 161); + expect(detail).toBeDefined(); + expect(detail!.message).toContain('outer: outer'); + expect(detail!.message).toContain('inner: inner'); + + const match = detail!.message.match(/logReplaySafetyDetail\((\d+)\)/); + expect(match).not.toBeNull(); + const detailed = logger.catch(() => + logReplaySafetyDetail(Number(match![1])) + ); + expect(detailed.info.map(v => v.code)).not.toContain(162); + expect(group).toHaveBeenCalled(); + expect(output).toHaveBeenCalled(); + }); + + // 验证已有收集进行时再次 begin 触发告警码 159 + it('warns code 159 when beginning during another collection', () => { + const system = new ReplaySystem(); + + const { info } = logger.catch(() => { + beginReplaySafetyCollection(system); + beginReplaySafetyCollection(system); + endReplaySafetyCollection(); + }); + + expect(info.map(v => v.code)).toContain(159); + }); + + // 验证收集外调用 end 触发告警码 160 + it('warns code 160 when ending outside a collection', () => { + const { info } = logger.catch(() => endReplaySafetyCollection()); + + expect(info.map(v => v.code)).toContain(160); + }); + + // 验证查询不存在的详情 code 触发告警码 162 + it('warns code 162 when the detail code is unknown', () => { + const { info } = logger.catch(() => logReplaySafetyDetail(987654)); + + expect(info.map(v => v.code)).toContain(162); + }); + + // 验证 ignoreReplay 跳过收集输出并在结束后完全重置生命周期 + it('skips the collection output when ignoreReplay ran', () => { + interface Fixture { + ignored(): void; + } + const ignored = ignoreReplay('async')(function ( + this: Fixture + ): void {}, methodContext('ignored')); + const fixture: Fixture = { ignored }; + const system = new ReplaySystem(); + + const first = logger.catch(() => { + beginReplaySafetyCollection(system); + fixture.ignored(); + endReplaySafetyCollection(); + }); + expect(first.info.map(v => v.code)).not.toContain(161); + + const second = logger.catch(() => { + beginReplaySafetyCollection(system); + endReplaySafetyCollection(); + }); + expect(second.info.map(v => v.code)).not.toContain(159); + }); + + // 验证收集未开始时 shouldReplay 直接透传原方法的返回值 + it('passes through decorated calls when no collection is active', () => { + interface Fixture { + compute(): number; + } + const compute = shouldReplay('compute')(function ( + this: Fixture + ): number { + return 42; + }, methodContext('compute')); + const fixture: Fixture = { compute }; + + expect(fixture.compute()).toBe(42); + }); +});