test(06-09): cover map, replay and flag save/load round trips

- add map/saveLoad.test.ts: StaticTile/DynamicTile/MapLayer/GameMap/MapState round trips, codes 55/122/124
- add replay/saveLoad.test.ts: ReplayArray and ReplaySystem same-instance round trips
- add flag/saveLoad.test.ts: FlagSystem round trip and separated snapshot
This commit is contained in:
unanmed 2026-09-14 18:57:51 +08:00
parent 3e29548bfa
commit c707c87add
3 changed files with 494 additions and 0 deletions

View File

@ -0,0 +1,78 @@
// 测试全局 Flag 系统存读档:同实例往返、占用状态与存档克隆
import { afterAll, describe, expect, it, vi } from 'vitest';
import { FlagSystem } from './system';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
value: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, value);
return value;
};
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
});
afterAll(() => {
vi.unstubAllGlobals();
});
describe('FlagSystem save and load round trips', () => {
// 验证字段数值与占用状态在同实例上恢复
it('restores field values and occupancy on the same instance', () => {
const system = new FlagSystem();
system.setFieldValue('score', 7);
system.addFieldValue('score', 5);
system.setFieldValue('name', 'hero');
const saved = system.saveState();
system.setFieldValue('score', 0);
system.deleteField('name');
system.loadState(saved);
expect(system.getFieldValue<number>('score')).toBe(12);
expect(system.occupied('score')).toBe(true);
expect(system.getFieldValue<string>('name')).toBe('hero');
expect(system.occupied('name')).toBe(true);
});
// 验证 saveState 返回分离克隆,修改存档不会影响系统
it('returns a separated clone from saveState', () => {
const system = new FlagSystem();
system.setFieldValue('score', 7);
const saved = system.saveState();
saved.fields.set('score', 999);
saved.fields.set('extra', 1);
expect(system.getFieldValue<number>('score')).toBe(7);
expect(system.occupied('extra')).toBe(false);
});
// 验证 loadState 会重建字段对象并保留恢复值
it('rebuilds field objects on loadState', () => {
const system = new FlagSystem();
const before = system.insertField('score', 7);
const saved = system.saveState();
system.loadState(saved);
expect(system.getField('score')).not.toBe(before);
expect(system.getFieldValue<number>('score')).toBe(7);
});
});

View File

@ -0,0 +1,292 @@
// 测试地图系统存读档:tile/MapLayer/GameMap/MapState 同实例往返、压缩档与码 55/122/124
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
Dir8FaceHandler,
FaceGroup,
FaceManager,
RoleFaceBinder,
SaveCompression,
TileStore,
TileType
} from '@user/data-common';
import { DirectionMapper, logger } from '@motajs/common';
import { type IGameMap, type IResizableMapLayer } from './types';
import { MapState } from './mapState';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
key: K,
value: V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
this.set(key, value);
return value;
};
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
});
afterAll(() => {
vi.unstubAllGlobals();
});
/** 存读档测试覆盖的三档压缩级别 */
const SAVE_COMPRESSIONS = [
SaveCompression.NoCompression,
SaveCompression.LowCompression,
SaveCompression.HighCompression
] as const;
interface MapFixture {
mapState: MapState;
map: IGameMap;
layer: IResizableMapLayer;
}
/** 构造一个含事件层的最小地图状态与楼层,可传入点事件与初始图块 */
function createMapFixture(
pointEvents: Record<number, Record<number, string>> = {},
blocks: number[] = [1, 1, 1, 1]
): MapFixture {
const tileStore = new TileStore();
tileStore.addTile({
num: 1,
id: 'base',
events: { 10: 'base-event' },
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
tileStore.addTile({
num: 2,
id: 'alternate',
events: { 20: 'alternate-event' },
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
});
const faceManager = new FaceManager();
faceManager.register(FaceGroup.Dir8, new Dir8FaceHandler());
const state: IDataCommon = {
tileStore,
itemStore: {},
mapStore: {},
eventStore: {},
roleFace: new RoleFaceBinder(),
faceManager,
directionMapper: new DirectionMapper(),
saveSystem: {}
} as never;
const mapState = new MapState(tileStore, state);
const map = mapState.fromRaw({
floorId: 'F1',
width: 2,
map: { 0: blocks },
layerAlias: { 0: 'event' },
events: { 0: pointEvents }
});
return {
mapState,
map: map!,
layer: map!.getLayerByAlias('event')! as IResizableMapLayer
};
}
/** 用当前图层矩阵作为参考基准,使 Low/High 压缩档具备可恢复基准 */
function setLayerReference(layer: IResizableMapLayer): void {
layer.compareWith(new Uint32Array(layer.getMapData()));
}
describe('StaticTile save and load round trips', () => {
// 验证静态图块覆盖事件在同实例上恢复且保存返回分离的事件映射
it('restores covered events on the same instance', () => {
const { layer } = createMapFixture();
const tile = layer.getTile(0, 0)!;
tile.tileEvent().set(30, 'override-event');
const saved = tile.saveState(SaveCompression.NoCompression);
const snapshot = new Map(saved.events!);
tile.tileEvent().set(30, 'changed-after-save');
expect(saved.events).toEqual(snapshot);
tile.loadState(saved, SaveCompression.NoCompression);
expect(tile.tileEvent().get()).toEqual(
new Map([
[10, 'base-event'],
[30, 'override-event']
])
);
});
});
describe('DynamicTile save and load round trips', () => {
// 验证动态图块覆盖事件在同实例上恢复,存档记录当前图块数字
it('restores covered events on the same instance', () => {
const { layer } = createMapFixture();
const tile = layer.createDynamic(1, 1, 0);
tile.tileEvent().set(30, 'override-event');
const saved = tile.saveState(SaveCompression.NoCompression);
tile.tileEvent().set(30, 'changed-after-save');
tile.loadState(saved, SaveCompression.NoCompression);
expect(saved.num).toBe(1);
expect(tile.num()).toBe(1);
expect(tile.tileEvent().get()).toEqual(
new Map([
[10, 'base-event'],
[30, 'override-event']
])
);
});
// 疑似 bug:loadState 不恢复存档中的图块数字,详见 06-TEST-FINDINGS.md #06-09-3
it.skip('restores the tile num on the same instance', () => {
const { layer } = createMapFixture();
const tile = layer.createDynamic(1, 1, 0);
const saved = tile.saveState(SaveCompression.NoCompression);
tile.set(2);
tile.loadState(saved, SaveCompression.NoCompression);
expect(tile.num()).toBe(1);
});
});
describe('MapLayer save and load round trips', () => {
// 验证矩阵与点事件在三个压缩档下同实例恢复
it('restores the matrix and point events across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const { layer } = createMapFixture({ 1: { 5: 'raw-point' } });
setLayerReference(layer);
const point = layer.event(1, 0)!;
point.set(7, 'saved-point');
layer.setBlock(9, 0, 0);
const saved = layer.saveState(compression);
point.set(7, 'changed-after-save');
layer.setBlock(0, 0, 0);
layer.loadState(saved, compression);
expect(layer.getBlock(0, 0)).toBe(9);
expect(point.get()).toEqual(
new Map([
[5, 'raw-point'],
[7, 'saved-point']
])
);
}
});
// 验证缺少参考基准的压缩档经 logger.catch 观测到警告码 124
it('warns code 124 when the compression reference is missing', () => {
const dirty = createMapFixture();
const lowSave = dirty.layer.saveState(SaveCompression.LowCompression);
const lowResult = logger.catch(() =>
dirty.layer.loadState(lowSave, SaveCompression.LowCompression)
);
const highSave = dirty.layer.saveState(SaveCompression.HighCompression);
const highResult = logger.catch(() =>
dirty.layer.loadState(highSave, SaveCompression.HighCompression)
);
expect(lowResult.info.map(info => info.code)).toContain(124);
expect(highResult.info.map(info => info.code)).toContain(124);
});
});
describe('GameMap save and load round trips', () => {
// 验证背景与图层矩阵在三个压缩档下同实例恢复
it('restores background and layer matrices across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const { map, layer } = createMapFixture();
setLayerReference(layer);
map.setBackground(7);
layer.setBlock(9, 0, 0);
const saved = map.saveState(compression);
map.setBackground(0);
layer.setBlock(0, 0, 0);
map.loadState(saved, compression);
expect(map.getBackground()).toBe(7);
expect(layer.getBlock(0, 0)).toBe(9);
}
});
});
describe('MapState save and load round trips', () => {
// 验证激活楼层矩阵在三个压缩档下同实例恢复
it('restores active floor matrices across all compressions', () => {
for (const compression of SAVE_COMPRESSIONS) {
const { mapState, map, layer } = createMapFixture();
map.setActiveStatus(true);
mapState.compareWith(
new Map([
['F1', new Map([[0, new Uint32Array(layer.getMapData())]])]
])
);
layer.setBlock(9, 0, 0);
const saved = mapState.saveState(compression);
layer.setBlock(0, 0, 0);
mapState.loadState(saved, compression);
expect(mapState.isMapActive('F1')).toBe(true);
expect(layer.getBlock(0, 0)).toBe(9);
}
});
// 验证非无压缩读档且未比较基准时经 logger.catch 观测到错误码 55
it('warns code 55 when loading a compressed MapState without a reference', () => {
const { mapState } = createMapFixture();
const result = logger.catch(() =>
mapState.loadState(
{ floors: new Map() },
SaveCompression.LowCompression
)
);
expect(result.info.map(info => info.code)).toContain(55);
});
// 验证存档中缺失楼层时经 logger.catch 观测到警告码 122
it('warns code 122 when a floor is missing during loadState', () => {
const { mapState, map } = createMapFixture();
const result = logger.catch(() =>
mapState.loadState(
{
floors: new Map([
[
'missing',
map.saveState(SaveCompression.NoCompression)
]
])
},
SaveCompression.NoCompression
)
);
expect(result.info.map(info => info.code)).toContain(122);
});
});

View File

@ -0,0 +1,124 @@
// 测试录像存读档:ReplayArray 与 ReplaySystem 同实例往返与多类型参数读回
import { afterAll, describe, expect, it, vi } from 'vitest';
import { ReplayArray } from './array';
import { ReplaySystem } from './system';
import { ReplayCommandWidth } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsertComputed ??= function <K, V>(
this: Map<K, V>,
key: K,
callback: (key: K) => V
): V {
const existing = this.get(key);
if (existing !== undefined) return existing;
const value = callback(key);
this.set(key, value);
return value;
};
});
afterAll(() => {
vi.unstubAllGlobals();
});
interface IArrayOverrides {
initCommandLength?: number;
initParamLength?: number;
commandExpandMultiplier?: number;
paramExpandMultiplier?: number;
commandWidth?: ReplayCommandWidth;
commandMaxLength?: number;
paramMaxLength?: number;
}
/** 与 system.ts 初始化保持一致的构造入口,默认给足初始容量避免无关扩容 */
function createArray(overrides: IArrayOverrides = {}): ReplayArray {
return new ReplayArray({
initCommandLength: 32,
initParamLength: 128,
commandExpandMultiplier: 2,
paramExpandMultiplier: 2,
commandWidth: ReplayCommandWidth.Uint8,
commandMaxLength: 64,
paramMaxLength: 512,
...overrides
});
}
/** 构造一个独立的录像系统实例 */
function createSystem(): ReplaySystem {
return new ReplaySystem();
}
describe('ReplayArray save and load round trips', () => {
// 验证指令位宽与首步命令及多类型参数在同实例上读回一致
it('restores the command width and reads back the first step on the same instance', () => {
const array = createArray();
array.add(3, [true, -128, 32768, 'hello', 5]);
const saved = array.saveState();
array.add(9, [1]);
array.loadState(saved);
expect(array.commandWidth).toBe(saved.metadata.commandWidth);
expect(array.get(0)).toEqual({
command: 3,
params: [true, -128, 32768, 'hello', 5],
index: 0
});
});
// 疑似 bug:loadState 未恢复录像长度与索引数组,详见 06-TEST-FINDINGS.md #06-09-4
it.skip('restores the recorded length on the same instance', () => {
const array = createArray();
array.add(1, [10]);
const saved = array.saveState();
array.add(2, [20]);
array.loadState(saved);
expect(array.length).toBe(1);
expect(array.get(0)).toEqual({ command: 1, params: [10], index: 0 });
});
});
describe('ReplaySystem save and load round trips', () => {
// 验证已注册指令与录像步在同实例上恢复到存档点
it('restores the route and registered commands on the same instance', () => {
const system = createSystem();
system.registerCommand(3, { execute: async () => true });
system.record(3, 1, true, 'param');
const snapshot = system.saveState();
system.record(4, 2);
system.replaying = true;
system.loadState(snapshot);
expect(system.route.length).toBe(1);
expect(system.route.commandWidth).toBe(snapshot.commandWidth);
expect(system.route.get(0)).toEqual({
command: 3,
params: [1, true, 'param'],
index: 0
});
expect(system.getCommand(3)).not.toBeNull();
expect(system.getCommand(4)).toBeNull();
});
// 验证读档后命令与参数数组与快照关键内容一致
it('keeps the command and param arrays consistent with the snapshot', () => {
const system = createSystem();
system.record(2, 7);
const snapshot = system.saveState();
system.record(5, 9);
system.loadState(snapshot);
expect(system.route.getCommandArray()).toBe(snapshot.commandArray);
expect(system.route.getParamArray()).toBe(snapshot.paramArray);
expect(system.route.get(0).command).toBe(2);
});
});