mirror of
https://github.com/motajs/template.git
synced 2026-09-13 10:18:50 +08:00
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:
parent
c06093839a
commit
1218504b5c
@ -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);
|
||||
}
|
||||
|
||||
44
packages-user/data-state/test/coreNode.test.ts
Normal file
44
packages-user/data-state/test/coreNode.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
292
packages-user/data-state/test/nodeReplay.test.ts
Normal file
292
packages-user/data-state/test/nodeReplay.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user