test(03-04): cover Node replay divergence and factory isolation

- Exercise success, false, throw, unknown, hero, and map mismatch paths
- Verify isolated Node CoreState instances and diagnostic parameter fidelity
- Repair replay route parameter encoding required by verifier diagnostics
This commit is contained in:
unanmed 2026-09-10 17:11:09 +08:00
parent c06093839a
commit 1218504b5c
4 changed files with 360 additions and 22 deletions

View File

@ -272,23 +272,23 @@ export class ReplayArray
return {
paramType: 6,
paramValue: arr,
byteLength: arr.length
byteLength: arr.length + 2
};
} else if (typeof param === 'string') {
const arr = this.textEncoder.encode(param);
if (arr.length < 248) {
if (arr.length > 0 && arr.length <= 248) {
// 8 ~ 255 - string
return {
paramType: arr.length + 8,
paramType: arr.length + 7,
paramValue: arr,
byteLength: arr.length
byteLength: arr.length + 1
};
} else {
// 7 - string
return {
paramType: 7,
paramValue: arr,
byteLength: arr.length
byteLength: arr.length + 5
};
}
}
@ -346,45 +346,47 @@ export class ReplayArray
* @param params
*/
private setParamArray(startIndex: number, params: INormalizedParam[]) {
let index = startIndex;
params.forEach(param => {
this.paramView.setInt8(startIndex, param.paramType);
this.paramView.setInt8(index, param.paramType);
const num = param.paramValue as number;
const arr = param.paramValue as Uint8Array;
if (param.paramType === 0) {
// 0 - boolean
this.paramView.setInt8(startIndex + 1, num);
this.paramView.setInt8(index + 1, num);
} else if (param.paramType === 1) {
// 1 - int8
this.paramView.setInt8(startIndex + 1, num);
this.paramView.setInt8(index + 1, num);
} else if (param.paramType === 2) {
// 2 - int16
this.paramView.setInt16(startIndex + 1, num);
this.paramView.setInt16(index + 1, num);
} else if (param.paramType === 3) {
// 3 - int32
this.paramView.setInt32(startIndex + 1, num);
this.paramView.setInt32(index + 1, num);
} else if (param.paramType === 4) {
// 4 - int64
const high = Math.floor(num / 2147483648);
const low = num % 2147483648;
this.paramView.setInt32(startIndex + 1, low);
this.paramView.setInt32(startIndex + 5, high);
this.paramView.setInt32(index + 1, low);
this.paramView.setInt32(index + 5, high);
} else if (param.paramType === 5) {
// 5 - float
this.paramView.setFloat64(startIndex + 1, num);
this.paramView.setFloat64(index + 1, num);
} else if (param.paramType === 6) {
// 6 - bigint
this.paramArray[startIndex + 1] = arr.length;
this.paramArray.set(arr, startIndex + 2);
this.paramArray[index + 1] = arr.length;
this.paramArray.set(arr, index + 2);
} else if (param.paramType === 7) {
// 7 - string
this.paramView.setInt32(startIndex + 1, arr.length);
this.paramArray.set(arr, startIndex + 5);
this.paramView.setInt32(index + 1, arr.length);
this.paramArray.set(arr, index + 5);
} else {
// 8 ~ 256 - string
this.paramArray.set(arr, startIndex + 1);
this.paramArray.set(arr, index + 1);
}
index += param.byteLength;
});
}
@ -631,13 +633,13 @@ export class ReplayArray
const length = this.paramView.getInt32(startIndex + 1);
const endIndex = startIndex + 5 + length;
const arr = this.paramArray.slice(startIndex + 5, endIndex);
byte = length + 2;
byte = length + 5;
value = this.textDecoder.decode(arr);
} else {
// 8 ~ 255 - string
const length = type - 7;
const endIndex = startIndex + 1 + length;
const arr = this.paramArray.slice(startIndex + 5, endIndex);
const arr = this.paramArray.slice(startIndex + 1, endIndex);
byte = length + 1;
value = this.textDecoder.decode(arr);
}

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { SaveCompression } from '@user/data-common';
import { createCoreState } from '../src/core';
describe('Node CoreState factory', () => {
// 验证无参数工厂创建的状态实例不会共享勇士、地图、事件存储和存档系统
it('creates independent mutable data-side instances', () => {
const first = createCoreState();
const second = createCoreState();
expect(first).not.toBe(second);
expect(first.hero).not.toBe(second.hero);
expect(first.hero.attribute).not.toBe(second.hero.attribute);
expect(first.eventStore).not.toBe(second.eventStore);
expect(first.saveSystem).not.toBe(second.saveSystem);
first.hero.getModifiableAttribute().set('hp', 99);
expect(second.hero.attribute.getFinalAttribute('hp')).not.toBe(99);
const map = first.maps.createMap('isolated-floor', 1, 1);
map.setActiveStatus(true);
const layer = map.addLayer();
layer.setMapRef(new Uint32Array([7]));
expect(second.maps.getMap('isolated-floor')).toBeNull();
const saved = first.maps.saveState(SaveCompression.NoCompression);
const savedFloor = saved.floors.get('isolated-floor');
const savedLayer = savedFloor?.layers.get(0);
if (!savedLayer?.fullMap)
throw new Error('missing isolated map snapshot');
savedLayer.fullMap[0] = 99;
expect(layer.getBlock(0, 0)).toBe(7);
});
// 验证 Node 工厂路径不读取浏览器宿主并使用独立的内存存档实现
it('constructs through the Node-safe path without browser globals', () => {
expect(() => createCoreState()).not.toThrow();
const state = createCoreState();
expect(state.saveSystem.constructor.name).toBe('MemorySaveSystem');
expect(state.maps).toBeDefined();
expect(state.eventStore).toBeDefined();
});
});

View File

@ -0,0 +1,292 @@
import { describe, expect, it } from 'vitest';
import { IReplayCommand, ReplayParamValue } from '@user/data-common';
import { ReplaySystem } from '../../data-common/src/replay/system';
import {
IReplayVerifierRuntime,
IReplayVerifierSnapshot,
verifyReplay
} from '../../../script/test-data-node';
enum TestCommandOutcome {
Success,
False,
Throw
}
interface ITestStep {
readonly code: number;
readonly params: readonly ReplayParamValue[];
}
interface ITestHarnessOptions {
readonly steps: readonly ITestStep[];
readonly commands: ReadonlyMap<number, IReplayCommand>;
readonly actual?: IReplayVerifierSnapshot;
}
interface ITestHarness {
readonly runtime: IReplayVerifierRuntime;
readonly calls: number[];
readonly finished: () => boolean;
}
interface ITestLayerSnapshot {
readonly zIndex: number;
readonly matrix: Uint32Array;
}
interface ITestFloorSnapshot {
readonly floorId: string;
readonly layers: readonly ITestLayerSnapshot[];
}
interface ITestExpectedSnapshot {
readonly hero: {
name: string;
hp: number;
hpmax: number;
atk: number;
def: number;
mdef: number;
mana: number;
manamax: number;
money: number;
exp: number;
};
readonly maps: readonly ITestFloorSnapshot[];
}
interface IVerifierDiagnostic {
readonly index: number;
readonly code: number;
readonly params: readonly ReplayParamValue[];
readonly reason: string;
readonly message: string;
}
function createSnapshot(): ITestExpectedSnapshot {
const layer: ITestLayerSnapshot = {
zIndex: 20,
matrix: new Uint32Array([1, 2])
};
const floor: ITestFloorSnapshot = {
floorId: 'F1',
layers: [layer]
};
return {
hero: {
name: '',
hp: 1,
hpmax: 0,
atk: 0,
def: 0,
mdef: 0,
mana: 0,
manamax: 0,
money: 0,
exp: 0
},
maps: [floor]
};
}
function createCommand(
outcome: TestCommandOutcome,
calls: number[],
message: string = 'test command failure'
): IReplayCommand {
return {
execute: async step => {
calls.push(step.index);
if (outcome === TestCommandOutcome.Throw) {
throw new Error(message);
}
return outcome === TestCommandOutcome.Success;
}
};
}
function createHarness(options: ITestHarnessOptions): ITestHarness {
const replay = new ReplaySystem();
for (const step of options.steps) {
replay.record(step.code, ...step.params);
}
for (const [code, command] of options.commands) {
replay.registerCommand(code, command);
}
const calls: number[] = [];
const sandbox = { ended: false };
let cursor = 0;
const runtime: IReplayVerifierRuntime = {
route: replay.route,
expected: createSnapshot(),
sandbox,
getCommand: code => replay.getCommand(code),
step: async () => {
const step = replay.route.get(cursor++);
const command = replay.getCommand(step.command);
if (!command) return false;
return command.execute(step);
},
finish: async () => {
sandbox.ended = true;
},
snapshot: () => options.actual ?? createSnapshot()
};
return {
runtime,
calls,
finished: () => sandbox.ended
};
}
async function expectDiagnostic(
runtime: IReplayVerifierRuntime
): Promise<IVerifierDiagnostic> {
let failure: unknown = null;
try {
await verifyReplay(runtime);
} catch (error) {
failure = error;
}
expect(failure).not.toBeNull();
expect(failure).toMatchObject({
index: expect.any(Number),
code: expect.any(Number),
params: expect.any(Array),
reason: expect.any(String)
});
return failure as IVerifierDiagnostic;
}
describe('Node replay verifier', () => {
// 验证固定成功路径在正常结束后比较完整勇士属性与所有地图矩阵
it('accepts a successful replay after end-only snapshot comparison', async () => {
const calls: number[] = [];
const command = createCommand(TestCommandOutcome.Success, calls);
const harness = createHarness({
steps: [{ code: 0, params: [] }],
commands: new Map([[0, command]])
});
await verifyReplay(harness.runtime);
expect(calls).toEqual([0]);
expect(harness.finished()).toBe(true);
});
// 验证未知命令在首个索引立即抛出并使用确定性安全参数展示
it('throws the first unknown-command diagnostic without executing a step', async () => {
const harness = createHarness({
steps: [{ code: 99, params: [1, 'safe', true, 2n] }],
commands: new Map()
});
const failure = await expectDiagnostic(harness.runtime);
expect(failure.index).toBe(0);
expect(failure.code).toBe(99);
expect(failure.params).toEqual([1, 'safe', true, 2n]);
expect(failure.reason).toContain('unknown replay command code 99');
expect(failure.message).toContain('params=[1, "safe", true, 2n]');
expect(harness.finished()).toBe(false);
});
// 验证 false 结果在首个索引停止且不会执行后续录像指令
it('throws on a false command result and stops before later commands', async () => {
const calls: number[] = [];
const first = createCommand(TestCommandOutcome.False, calls);
const second = createCommand(TestCommandOutcome.Success, calls);
const harness = createHarness({
steps: [
{ code: 5, params: [7] },
{ code: 6, params: [] }
],
commands: new Map([
[5, first],
[6, second]
])
});
const failure = await expectDiagnostic(harness.runtime);
expect(failure.index).toBe(0);
expect(failure.code).toBe(5);
expect(failure.params).toEqual([7]);
expect(failure.reason).toBe('command returned false');
expect(calls).toEqual([0]);
expect(harness.finished()).toBe(false);
});
// 验证 command throw 在首个索引停止并保留原始参数与可读原因
it('throws on a command exception and does not execute later commands', async () => {
const calls: number[] = [];
const first = createCommand(TestCommandOutcome.Throw, calls, 'boom');
const second = createCommand(TestCommandOutcome.Success, calls);
const harness = createHarness({
steps: [
{ code: 6, params: ['first'] },
{ code: 7, params: ['later'] }
],
commands: new Map([
[6, first],
[7, second]
])
});
const failure = await expectDiagnostic(harness.runtime);
expect(failure.index).toBe(0);
expect(failure.code).toBe(6);
expect(failure.params).toEqual(['first']);
expect(failure.reason).toBe('command threw: boom');
expect(calls).toEqual([0]);
expect(harness.finished()).toBe(false);
});
// 验证勇士完整属性差异只在录像正常结束后报告
it('reports a hero snapshot mismatch only after normal end', async () => {
const calls: number[] = [];
const actual = createSnapshot();
actual.hero.hp = 2;
const harness = createHarness({
steps: [{ code: 0, params: [] }],
commands: new Map([
[0, createCommand(TestCommandOutcome.Success, calls)]
]),
actual
});
const failure = await expectDiagnostic(harness.runtime);
expect(failure.index).toBe(0);
expect(failure.code).toBe(0);
expect(failure.reason).toContain('hero attribute hp differs');
expect(calls).toEqual([0]);
expect(harness.finished()).toBe(true);
});
// 验证所有地图图层矩阵差异只在录像正常结束后报告
it('reports a map matrix mismatch only after normal end', async () => {
const calls: number[] = [];
const actual = createSnapshot();
actual.maps[0].layers[0].matrix[1] = 9;
const harness = createHarness({
steps: [{ code: 0, params: [] }],
commands: new Map([
[0, createCommand(TestCommandOutcome.Success, calls)]
]),
actual
});
const failure = await expectDiagnostic(harness.runtime);
expect(failure.index).toBe(0);
expect(failure.code).toBe(0);
expect(failure.reason).toContain(
'map floor F1 layer 20 index 1 differs'
);
expect(calls).toEqual([0]);
expect(harness.finished()).toBe(true);
});
});

View File

@ -40,8 +40,6 @@ interface IClosedLoopModule {
}
const require = createRequire(import.meta.url);
const closedLoopModule =
require('../packages-user/data-state/test/fixtures/closed-loop.ts') as IClosedLoopModule;
interface IReplayVerifierSandbox {
readonly ended: boolean;
@ -301,6 +299,8 @@ function createRuntime(fixture: IClosedLoopFixture): IReplayVerifierRuntime {
}
export async function runNodeReplayVerifier(): Promise<void> {
const closedLoopModule =
require('../packages-user/data-state/test/fixtures/closed-loop.ts') as IClosedLoopModule;
const fixture = closedLoopModule.createClosedLoopFixture();
await verifyReplay(createRuntime(fixture));
}