test(06-06): cover map registration, layers and dynamic conversion (stage 2)

- mapState.test.ts: createMap/getMap, raw validation codes 60/61/62/63/64, activation, areas, compareWith
- gameMap.test.ts: layer lifecycle, aliases 84, background, event layer 131, dirty, resizeLayer, compareWith
- mapLayer.test.ts: matrix, getMapData 80/81, putMapData 8/9, setMapRef 123, dynamic conversion 127/128/129/130, point events, dirty/compareWith, async doors 46
- eventView extension plus stage 1 files already committed
This commit is contained in:
unanmed 2026-09-14 16:26:10 +08:00
parent 4cffe90794
commit b971e145e9
3 changed files with 1213 additions and 0 deletions

View File

@ -0,0 +1,254 @@
// 测试 GameMap 的图层生命周期、别名、背景、事件层、脏标记与尺寸变化
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type ITileRawData,
Dir8FaceHandler,
FaceGroup,
FaceManager,
RoleFaceBinder,
TileStore,
TileType
} from '@user/data-common';
import { DirectionMapper, logger } from '@motajs/common';
import { type IMapLayer } from './types';
import { GameMap } from './gameMap';
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();
});
/**
*
* @param num
* @param id id
* @param events
*/
function createTileData(
num: number,
id: string,
events: Record<number, string> = {}
): ITileRawData {
return {
num,
id,
events,
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
};
}
interface GameMapFixture {
map: GameMap;
tileStore: TileStore;
}
/** 构造一个空的 GameMap 及其底层公共层对象 */
function createFixture(): GameMapFixture {
const tileStore = new TileStore();
tileStore.addTile(createTileData(1, 'base', { 10: 'base-event' }));
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;
return {
map: new GameMap(state, tileStore, 'F1', 2, 2),
tileStore
};
}
describe('GameMap layer lifecycle', () => {
// 验证 addLayer/removeLayer/hasLayer 与图层列表钩子
it('adds and removes layers and notifies the layer list hook', () => {
const { map } = createFixture();
const sizes: number[] = [];
map.addHook({
onUpdateLayer: list => {
sizes.push(list.size);
}
}).load();
const layer = map.addLayer();
expect(map.hasLayer(layer)).toBe(true);
expect(sizes).toEqual([1]);
map.removeLayer(layer);
expect(map.hasLayer(layer)).toBe(false);
expect(sizes).toEqual([1, 0]);
});
// 验证别名设置、查询与重复别名告警 84
it('binds aliases and warns 84 on a duplicate alias', () => {
const { map } = createFixture();
const first = map.addLayer();
const second = map.addLayer();
map.setLayerAlias(first, 'event');
expect(map.getLayerByAlias('event')).toBe(first);
expect(map.getLayerAlias(first)).toBe('event');
const result = logger.catch(() => map.setLayerAlias(second, 'event'));
expect(result.info.map(info => info.code)).toContain(84);
expect(map.getLayerByAlias('event')).toBe(first);
expect(map.getLayerAlias(second)).toBeUndefined();
});
// 验证移除带别名的图层会一并清除别名映射
it('drops the alias mapping when the layer is removed', () => {
const { map } = createFixture();
const layer = map.addLayer();
map.setLayerAlias(layer, 'event');
map.removeLayer(layer);
expect(map.getLayerByAlias('event')).toBeNull();
expect(map.getLayerAlias(layer)).toBeUndefined();
});
});
describe('GameMap settings', () => {
// 验证背景设置、读取与背景变化钩子
it('stores the background and notifies the background hook', () => {
const { map } = createFixture();
const backgrounds: number[] = [];
map.addHook({
onChangeBackground: tile => {
backgrounds.push(tile);
}
}).load();
expect(map.getBackground()).toBe(0);
map.setBackground(7);
expect(map.getBackground()).toBe(7);
expect(backgrounds).toEqual([7]);
});
// 验证激活状态切换
it('toggles the active flag', () => {
const { map } = createFixture();
expect(map.active).toBe(false);
map.setActiveStatus(true);
expect(map.active).toBe(true);
});
// 验证事件层只接受本楼层的图层,越权告警 131空值清空
it('accepts own layers, warns 131 for a foreign layer and clears on null', () => {
const { map, tileStore } = createFixture();
const layer = map.addLayer();
const foreign = new GameMap(
map.state,
tileStore,
'other',
2,
2
).addLayer();
const result = logger.catch(() => map.setEventLayer(foreign));
expect(result.info.map(info => info.code)).toContain(131);
expect(map.eventLayer).toBeNull();
map.setEventLayer(layer);
expect(map.eventLayer).toBe(layer);
map.setEventLayer(null);
expect(map.eventLayer).toBeNull();
});
});
describe('GameMap dirty state and resizing', () => {
// 验证自身脏标记与图层脏标记的合并判定
it('reports dirty from itself or any layer', () => {
const { map } = createFixture();
const layer = map.addLayer();
expect(map.dirty()).toBe(false);
map.markDirty(true);
expect(map.dirty()).toBe(true);
map.markDirty(false);
expect(map.dirty()).toBe(false);
layer.setBlock(1, 0, 0);
expect(map.dirty()).toBe(true);
});
// 验证 resizeLayer 按 keepBlock 保留或清空图块并触发尺寸钩子
it('resizes layers keeping or clearing blocks and notifies hooks', () => {
const { map } = createFixture();
const layer = map.addLayer();
layer.setBlock(1, 0, 0);
const resizes: [number, number][] = [];
map.addHook({
onResizeLayer: (_layer: IMapLayer, width, height) => {
resizes.push([width, height]);
}
}).load();
map.resizeLayer(3, 2, true);
expect(map.width).toBe(3);
expect(map.height).toBe(2);
expect(layer.getBlock(0, 0)).toBe(1);
expect(resizes).toEqual([[3, 2]]);
map.resizeLayer(1, 1, false);
expect(layer.getBlock(0, 0)).toBe(0);
expect(resizes).toEqual([
[3, 2],
[1, 1]
]);
});
// 验证 compareWith 对缺失参考的图层标脏,对匹配参考的图层保持干净
it('marks layers dirty for a missing reference and compares matching ones', () => {
const { map } = createFixture();
const layer = map.addLayer();
layer.setZIndex(0);
map.compareWith(new Map());
expect(layer.dirty()).toBe(true);
const compared = createFixture().map;
const comparedLayer = compared.addLayer();
comparedLayer.setZIndex(0);
compared.compareWith(new Map([[0, new Uint32Array(4)]]));
expect(comparedLayer.dirty()).toBe(false);
});
});

View File

@ -0,0 +1,603 @@
// 测试 MapLayer 的矩阵读写、静态数组、动态转换、点位事件、脏标记与开关门
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IRoleFaceBinder,
type ITileRawData,
Dir8FaceHandler,
FaceDirection,
FaceGroup,
FaceManager,
RoleFaceBinder,
TileStore,
TileType
} from '@user/data-common';
import { DirectionMapper, logger } from '@motajs/common';
import {
type IDynamicTile,
type IGameMap,
type IResizableMapLayer
} from './types';
import { DynamicTile } from './dynamicTile';
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();
});
/** 允许以 0~4 个参数调用 getMapData 的宽松签名,用于触发参数个数告警 */
interface LooseMapDataArgs {
(x?: number, y?: number, width?: number, height?: number): Uint32Array;
}
/**
*
* @param num
* @param id id
* @param events
*/
function createTileData(
num: number,
id: string,
events: Record<number, string> = {}
): ITileRawData {
return {
num,
id,
events,
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
};
}
interface LayerFixture {
map: IGameMap;
layer: IResizableMapLayer;
roleFace: IRoleFaceBinder;
}
/** 构造一个含单事件图层的小地图,作为 MapLayer 的宿主 */
function createFixture(blocks: number[] = [1, 2, 1, 2]): LayerFixture {
const tileStore = new TileStore();
tileStore.addTile(createTileData(1, 'base', { 10: 'base-event' }));
tileStore.addTile(
createTileData(2, 'alternate', { 20: 'alternate-event' })
);
const roleFace = new RoleFaceBinder();
const faceManager = new FaceManager();
faceManager.register(FaceGroup.Dir8, new Dir8FaceHandler());
const state: IDataCommon = {
tileStore,
itemStore: {},
mapStore: {},
eventStore: {},
roleFace,
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: {} }
})!;
return {
map,
layer: map.getLayerByAlias('event')! as IResizableMapLayer,
roleFace
};
}
describe('MapLayer static matrix', () => {
// 验证 setBlock/getBlock/removeBlock/inMap 的边界与返回值
it('reads and writes blocks with boundary handling', () => {
const { layer } = createFixture();
expect(layer.inMap(0, 0)).toBe(true);
expect(layer.inMap(9, 9)).toBe(false);
expect(layer.getBlock(0, 0)).toBe(1);
expect(layer.getBlock(9, 9)).toBe(-1);
expect(layer.removeBlock(9, 9)).toBe(-1);
layer.setBlock(2, 0, 0);
expect(layer.getBlock(0, 0)).toBe(2);
expect(layer.removeBlock(0, 0)).toBe(2);
expect(layer.getBlock(0, 0)).toBe(0);
});
// 验证 setBlock 仅在改变内容时标记脏并触发图块更新钩子
it('marks dirty and fires the block hook only on an actual change', () => {
const { layer } = createFixture();
const updates: [number, number, number][] = [];
layer
.addHook({
onUpdateBlock: (block, x, y) => {
updates.push([block, x, y]);
}
})
.load();
expect(layer.dirty()).toBe(false);
layer.setBlock(1, 0, 0);
expect(layer.dirty()).toBe(false);
expect(updates).toEqual([]);
layer.setBlock(3, 0, 0);
expect(layer.dirty()).toBe(true);
expect(updates).toEqual([[3, 0, 0]]);
});
// 验证 getMapData 无参返回整图拷贝,修改拷贝不影响图层
it('returns an isolated full-map copy', () => {
const { layer } = createFixture();
const full = layer.getMapData();
expect([...full]).toEqual([1, 2, 1, 2]);
full[0] = 9;
expect(layer.getBlock(0, 0)).toBe(1);
});
// 验证 getMapData 四参返回指定区域的拷贝
it('returns the requested sub-region', () => {
const { layer } = createFixture();
expect([...layer.getMapData(0, 0, 1, 1)]).toEqual([1]);
expect([...layer.getMapData(1, 0, 1, 2)]).toEqual([2, 2]);
expect([...layer.getMapData(0, 1, 2, 1)]).toEqual([1, 2]);
});
// 验证 getMapData 参数个数非法时告警 80
it('warns code 80 for an illegal argument count', () => {
const { layer } = createFixture();
const result = logger.catch(() =>
(layer.getMapData as LooseMapDataArgs)(1)
);
expect(result.info.map(info => info.code)).toContain(80);
expect(result.ret).toHaveLength(0);
});
// 验证 getMapData 区域越界时告警 81 并补零
it('warns code 81 for an out-of-range region', () => {
const { layer } = createFixture();
const result = logger.catch(() => layer.getMapData(1, 1, 2, 2));
expect(result.info.map(info => info.code)).toContain(81);
expect([...result.ret]).toEqual([2, 0, 0, 0]);
});
// 验证 putMapData 全量写入与局部写入
it('writes whole and partial regions through putMapData', () => {
const { layer } = createFixture([0, 0, 0, 0]);
layer.putMapData(new Uint32Array([2, 2, 1, 1]), 0, 0, 2);
expect([...layer.getMapData()]).toEqual([2, 2, 1, 1]);
layer.putMapData(new Uint32Array([5]), 1, 0, 1);
expect(layer.getBlock(1, 0)).toBe(5);
expect(layer.getBlock(0, 0)).toBe(2);
});
// 验证 putMapData 数据不完整时告警 8区域越界时告警 9
it('warns code 8 for incomplete data and code 9 for an out-of-range area', () => {
const { layer } = createFixture([0, 0, 0, 0]);
const incomplete = logger.catch(() =>
layer.putMapData(new Uint32Array([1, 2, 3]), 0, 0, 2)
);
expect(incomplete.info.map(info => info.code)).toContain(8);
expect(incomplete.info.map(info => info.code)).not.toContain(9);
const overflow = logger.catch(() =>
layer.putMapData(new Uint32Array([1, 2, 3, 4]), 1, 0, 3)
);
const codes = overflow.info.map(info => info.code);
expect(codes).toContain(8);
expect(codes).toContain(9);
});
// 验证 setMapRef 长度不符时告警 123匹配时替换内部引用
it('warns code 123 on a length mismatch and replaces the reference otherwise', () => {
const { layer } = createFixture();
const before = layer.getMapRef();
const result = logger.catch(() =>
layer.setMapRef(new Uint32Array([0, 0, 0]))
);
expect(result.info.map(info => info.code)).toContain(123);
expect(layer.getMapRef()).toBe(before);
const replacement = new Uint32Array([3, 3, 3, 3]);
layer.setMapRef(replacement);
expect(before.expired).toBe(true);
expect(layer.getMapRef().array).toBe(replacement);
expect(layer.getMapRef().expired).toBe(false);
expect(layer.empty).toBe(false);
});
// 验证 iterateBlocks 只产出非空图块位置
it('iterates only non-empty blocks', () => {
const { layer } = createFixture([1, 0, 0, 2]);
const locations = [...layer.iterateBlocks()];
expect(locations.map(location => location.tile)).toEqual([1, 2]);
expect(locations.map(location => location.locator)).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]);
expect(layer.empty).toBe(false);
});
});
describe('MapLayer point events', () => {
// 验证 event/getPointEvent 的越图空值与内容读取
it('reads point events and returns null outside the map', () => {
const { layer } = createFixture();
expect(layer.event(9, 9)).toBeNull();
expect(layer.getPointEvent(9, 9)).toBeNull();
expect(layer.getPointEvent(0, 0)).toEqual(new Map());
layer.event(0, 0)!.set(5, 'point-event');
expect(layer.getPointEvent(0, 0)).toEqual(
new Map([[5, 'point-event']])
);
});
// 验证 getTile 缓存同一实例getLocationData 汇总静态与动态图块
it('caches static tiles and aggregates location data', () => {
const { layer } = createFixture();
expect(layer.getTile(0, 0)).toBe(layer.getTile(0, 0));
expect(layer.getTile(9, 9)).toBeNull();
expect(layer.getLocationData(9, 9)).toBeNull();
const dynamic = layer.createDynamic(2, 0, 0);
const location = layer.getLocationData(0, 0)!;
expect(location.locator).toEqual({ x: 0, y: 0 });
expect(location.tile).toBe(1);
expect(location.static?.num()).toBe(1);
expect([...location.dynamics]).toEqual([dynamic]);
});
});
describe('MapLayer directions and layer attributes', () => {
// 验证 setStaticDirection 经朝向绑定器映射并处理越图
it('maps static directions and reports out-of-map as -1', () => {
const { layer, roleFace } = createFixture([1, 1, 1, 1]);
roleFace.malloc(1, FaceDirection.Down);
roleFace.bind(2, 1, FaceDirection.Right);
expect(layer.setStaticDirection(0, 0, FaceDirection.Right)).toBe(2);
expect(layer.getBlock(0, 0)).toBe(2);
expect(layer.setStaticDirection(9, 9, FaceDirection.Right)).toBe(-1);
});
// 验证 setDynamicDirection 优先按绑定映射,否则回退四方向降级
it('maps dynamic directions and degrades unmapped diagonals', () => {
const { layer, roleFace } = createFixture([0, 0, 0, 0]);
roleFace.malloc(1, FaceDirection.Down);
roleFace.bind(2, 1, FaceDirection.Right);
const tile = layer.createDynamic(1, 0, 0);
expect(layer.setDynamicDirection(tile, FaceDirection.Right)).toBe(2);
expect(tile.num()).toBe(2);
const fallback = layer.createDynamic(1, 1, 0);
expect(layer.setDynamicDirection(fallback, FaceDirection.Up)).toBe(1);
});
// 验证 setZIndex 与 setFaceBinder 的写入与空值忽略
it('sets the z-index and swaps the face binder while ignoring null', () => {
const { layer } = createFixture();
const replacement = new RoleFaceBinder();
layer.setZIndex(5);
expect(layer.zIndex).toBe(5);
layer.setFaceBinder(replacement);
expect(layer.faceBinder).toBe(replacement);
layer.setFaceBinder(null);
expect(layer.faceBinder).toBe(replacement);
});
});
describe('MapLayer dynamic conversion', () => {
// 验证 createDynamic 建立索引并触发创建钩子
it('creates dynamic tiles and notifies the create hook', () => {
const { layer } = createFixture([0, 0, 0, 0]);
const created: IDynamicTile[] = [];
layer
.addHook({
onCreateDynamic: tile => {
created.push(tile);
}
})
.load();
const tile = layer.createDynamic(1, 0, 0);
expect(created).toEqual([tile]);
expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([tile]);
expect([...layer.iterateDynamicTiles()]).toEqual([tile]);
});
// 验证 updateDynamicTile 按新坐标重建位置索引
it('reindexes a moved dynamic tile', () => {
const { layer } = createFixture([0, 0, 0, 0]);
const tile = layer.createDynamic(1, 0, 0) as DynamicTile;
tile.x = 1;
tile.y = 1;
layer.updateDynamicTile(tile);
expect([...layer.getDynamicTilesAt(1, 1)]).toEqual([tile]);
expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]);
});
// 验证 transferToDynamic 清空静态图块并默认把默认事件带给动态图块
it('transfers a block to a dynamic tile keeping the default events', () => {
const { layer } = createFixture();
layer.getTile(0, 0);
const tile = layer.transferToDynamic(0, 0)!;
expect(tile.num()).toBe(1);
expect(layer.getBlock(0, 0)).toBe(0);
expect(tile.tileEvent().get()).toEqual(new Map([[10, 'base-event']]));
expect(layer.getTile(0, 0)!.tileEvent().get()).toEqual(new Map());
});
// 验证 transferToDynamic 在 keepEvent 为 false 时清空动态图块事件
it('clears dynamic events when transferToDynamic does not keep them', () => {
const { layer } = createFixture();
const tile = layer.transferToDynamic(0, 0, false)!;
expect(tile.tileEvent().get()).toEqual(new Map());
expect(layer.getTile(0, 0)!.tileEvent().get()).toEqual(new Map());
});
// 验证 transferToDynamic 对空白格告警 127 并产生数字 0 的动态图块
it('warns code 127 when transferring an empty block', () => {
const { layer } = createFixture([0, 0, 0, 0]);
const result = logger.catch(() => layer.transferToDynamic(0, 0));
expect(result.info.map(info => info.code)).toContain(127);
expect(result.ret?.num()).toBe(0);
});
// 验证越图的 transferToDynamic 不产生图块
it('returns null for an out-of-map transferToDynamic', () => {
const { layer } = createFixture();
logger.catch(() => layer.transferToDynamic(9, 9));
expect(layer.getBlock(0, 0)).toBe(1);
expect([...layer.iterateDynamicTiles()]).toEqual([]);
});
// 疑似缺陷 #06-06-1transferToDynamic 越图当前复用码 131setEventLayer 专属码),
// 正确预期应为与 transferToStatic 一致的越界码 128待用户确认后取消 skip
it.skip('warns code 128 for an out-of-map transferToDynamic', () => {
const { layer } = createFixture();
const result = logger.catch(() => layer.transferToDynamic(9, 9));
expect(result.info.map(info => info.code)).toContain(128);
expect(result.ret).toBeNull();
});
// 验证 transferToStatic 保留动态事件并写回静态图块
it('transfers a dynamic tile back to static keeping events', () => {
const { layer } = createFixture();
layer.getTile(0, 0);
const tile = layer.transferToDynamic(0, 0)!;
tile.tileEvent().set(30, 'kept-event');
const restored = layer.transferToStatic(tile, true)!;
expect(restored.num()).toBe(1);
expect(layer.getBlock(0, 0)).toBe(1);
expect(restored.tileEvent().get()).toEqual(
new Map([
[10, 'base-event'],
[30, 'kept-event']
])
);
expect(restored.tileEvent().dirty()).toBe(true);
});
// 验证 transferToStatic 在 keepEvent 为 false 时恢复静态默认事件
it('restores static defaults when transferToStatic does not keep events', () => {
const { layer } = createFixture();
const tile = layer.transferToDynamic(0, 0)!;
tile.tileEvent().set(30, 'discarded-event');
const restored = layer.transferToStatic(tile, false)!;
expect(restored.tileEvent().get()).toEqual(
new Map([[10, 'base-event']])
);
expect(restored.tileEvent().dirty()).toBe(false);
});
// 验证 transferToStatic 对越界坐标告警 128 并拒绝转换
it('warns code 128 when transferToStatic is out of bounds', () => {
const { layer } = createFixture();
const tile = layer.createDynamic(1, 0, 0) as DynamicTile;
tile.x = 9;
const result = logger.catch(() => layer.transferToStatic(tile));
expect(result.info.map(info => info.code)).toContain(128);
expect(result.ret).toBeNull();
});
// 验证 transferToStatic 覆盖非空静态格时告警 129
it('warns code 129 when transferToStatic overwrites a static block', () => {
const { layer } = createFixture([2, 0, 0, 0]);
const tile = layer.createDynamic(1, 0, 0);
const result = logger.catch(() => layer.transferToStatic(tile));
expect(result.info.map(info => info.code)).toContain(129);
expect(layer.getBlock(0, 0)).toBe(1);
});
// 验证 transferToStaticIfSafe 仅在目标为空时转换
it('only converts with transferToStaticIfSafe when the target is empty', () => {
const occupied = createFixture([2, 0, 0, 0]);
const blocked = occupied.layer.createDynamic(1, 0, 0);
expect(occupied.layer.transferToStaticIfSafe(blocked)).toBeNull();
const empty = createFixture([0, 0, 0, 0]);
const tile = empty.layer.createDynamic(1, 0, 0);
expect(empty.layer.transferToStaticIfSafe(tile)?.num()).toBe(1);
expect(empty.layer.getBlock(0, 0)).toBe(1);
});
// 验证 deleteDynamic 移除索引并触发删除钩子
it('deletes a dynamic tile and notifies the delete hook', async () => {
const { layer } = createFixture([0, 0, 0, 0]);
const deleted: IDynamicTile[] = [];
layer
.addHook({
onDeleteDynamic: async tile => {
deleted.push(tile);
}
})
.load();
const tile = layer.createDynamic(1, 0, 0);
await layer.deleteDynamic(tile);
expect(deleted).toEqual([tile]);
expect([...layer.iterateDynamicTiles()]).toEqual([]);
});
// 验证 deleteDynamic 对非本层图块告警 130
it('warns code 130 for a tile not managed by the layer', async () => {
const { layer } = createFixture([0, 0, 0, 0]);
const tile = layer.createDynamic(1, 0, 0);
await layer.deleteDynamic(tile);
const result = logger.catch(() => layer.deleteDynamic(tile));
expect(result.info.map(info => info.code)).toContain(130);
await result.ret;
});
});
describe('MapLayer dirty state and doors', () => {
// 验证图层脏标记、点事件脏标记与参考基准比较
it('combines the layer flag, point-event dirtiness and the reference', () => {
const { layer } = createFixture();
expect(layer.dirty()).toBe(false);
layer.markDirty(true);
expect(layer.dirty()).toBe(true);
layer.markDirty(false);
layer.event(0, 0)!.set(5, 'point-event');
expect(layer.dirty()).toBe(true);
layer.event(0, 0)!.delete(5);
layer.compareWith(new Uint32Array([1, 2, 1, 2]));
expect(layer.dirty()).toBe(false);
});
// 验证 compareWith 与参考不一致时标记为脏且只生效一次
it('marks dirty against a differing reference and applies only once', () => {
const { layer } = createFixture();
layer.compareWith(new Uint32Array([0, 0, 0, 0]));
expect(layer.dirty()).toBe(true);
layer.markDirty(false);
layer.compareWith(new Uint32Array([1, 2, 1, 2]));
expect(layer.dirty()).toBe(false);
});
// 验证 openDoor 等钩子完成后清空门图块,空位为 no-op
it('opens a door after awaiting the hook and ignores empty positions', async () => {
const { layer } = createFixture([0, 0, 0, 0]);
const opened: [number, number][] = [];
layer
.addHook({
onOpenDoor: async (x, y) => {
opened.push([x, y]);
}
})
.load();
layer.setBlock(1, 0, 0);
await layer.openDoor(0, 0);
expect(opened).toEqual([[0, 0]]);
expect(layer.getBlock(0, 0)).toBe(0);
await layer.openDoor(1, 1);
expect(opened).toEqual([[0, 0]]);
});
// 验证 closeDoor 在空位关门,目标非空时告警 46 且不覆盖
it('closes a door on empty cells and warns 46 on occupied cells', async () => {
const { layer } = createFixture([0, 0, 0, 0]);
const closed: [number, number, number][] = [];
layer
.addHook({
onCloseDoor: async (num, x, y) => {
closed.push([num, x, y]);
}
})
.load();
await layer.closeDoor(3, 0, 0);
expect(closed).toEqual([[3, 0, 0]]);
expect(layer.getBlock(0, 0)).toBe(3);
const result = logger.catch(() => layer.closeDoor(5, 0, 0));
expect(result.info.map(info => info.code)).toContain(46);
await result.ret;
expect(layer.getBlock(0, 0)).toBe(3);
expect(closed).toEqual([[3, 0, 0]]);
});
});

View File

@ -0,0 +1,356 @@
// 测试 MapState 的楼层注册、原始数据校验、激活/分区与参考基准比较
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IMapRawData,
type ITileRawData,
Dir8FaceHandler,
FaceGroup,
FaceManager,
RoleFaceBinder,
TileStore,
TileType
} from '@user/data-common';
import { DirectionMapper, logger } from '@motajs/common';
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();
});
/**
*
* @param num
* @param id id
* @param events
*/
function createTileData(
num: number,
id: string,
events: Record<number, string> = {}
): ITileRawData {
return {
num,
id,
events,
type: TileType.Terrain,
pass: { onlyEvents: false, outPass: 15, inPass: 15 },
eventPass: true
};
}
/** 构造一个可用的 MapState 及其底层公共层对象 */
function createFixture(): MapState {
const tileStore = new TileStore();
tileStore.addTile(createTileData(1, 'base', { 10: 'base-event' }));
tileStore.addTile(
createTileData(2, 'alternate', { 20: 'alternate-event' })
);
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;
return new MapState(tileStore, state);
}
/** 构造一份合法的楼层原始数据 */
function createRaw(): IMapRawData {
return {
floorId: 'F1',
width: 2,
map: { 0: [1, 2, 1, 2] },
layerAlias: { 0: 'event' },
events: { 0: { 1: { 5: 'point-event' } } }
};
}
describe('MapState floor registration', () => {
// 验证 createMap 注册楼层、重复注册告警 121 并覆盖旧实例
it('registers floors and warns 121 when the floor already exists', () => {
const mapState = createFixture();
const first = mapState.createMap('F1', 2, 2);
expect(mapState.getMap('F1')).toBe(first);
expect(mapState.maps).toEqual(['F1']);
expect(mapState.getMap('unknown')).toBeNull();
const repeated = logger.catch(() => mapState.createMap('F1', 2, 2));
expect(repeated.info.map(info => info.code)).toContain(121);
expect(mapState.getMap('F1')).not.toBe(first);
expect(mapState.maps).toEqual(['F1']);
});
// 验证 setMapList 去重并保持传入顺序
it('stores a de-duplicated ordered floor list', () => {
const mapState = createFixture();
mapState.setMapList(['A', 'B', 'A', 'C']);
expect(mapState.maps).toEqual(['A', 'B', 'C']);
});
// 验证 fromRaw 构建楼层、绑定事件层与点事件,并默认处于未激活
it('builds a floor, binds the event layer and stays inactive by default', () => {
const mapState = createFixture();
const map = mapState.fromRaw(createRaw());
expect(map).not.toBeNull();
const layer = map!.getLayerByAlias('event');
expect(layer).not.toBeNull();
expect(map!.eventLayer).toBe(layer);
expect(layer!.getPointEvent(1, 0)).toEqual(
new Map([[5, 'point-event']])
);
expect(map!.active).toBe(false);
expect(mapState.isMapActive('F1')).toBe(false);
expect(mapState.getActiveMap('F1')).toBeNull();
expect(mapState.getMap('F1')).toBe(map);
});
});
describe('MapState raw validation codes', () => {
interface MalformedCase {
name: string;
mutate(raw: IMapRawData): void;
code: number;
}
const cases: MalformedCase[] = [
{
name: 'unequal layer lengths',
mutate: raw => {
Reflect.set(raw, 'map', {
0: [1, 1, 1, 1],
1: [1, 1, 1]
});
Reflect.set(raw, 'layerAlias', { 0: 'a', 1: 'b' });
Reflect.set(raw, 'events', { 0: {}, 1: {} });
},
code: 60
},
{
name: 'area not divisible by width',
mutate: raw => {
Reflect.set(raw, 'map', { 0: [1, 1, 1, 1, 1] });
Reflect.set(raw, 'layerAlias', { 0: 'a' });
Reflect.set(raw, 'events', { 0: {} });
},
code: 61
},
{
name: 'non-numeric map layer key',
mutate: raw => Reflect.set(raw.map, 'bad', [1, 2, 1, 2]),
code: 62
},
{
name: 'non-numeric event priority',
mutate: raw => Reflect.set(raw.events[0][1], 'bad', 'event'),
code: 62
},
{
name: 'missing map container',
mutate: raw => Reflect.set(raw, 'map', null),
code: 63
},
{
name: 'missing events container',
mutate: raw => Reflect.set(raw, 'events', null),
code: 63
},
{
name: 'missing alias container',
mutate: raw => Reflect.set(raw, 'layerAlias', null),
code: 63
},
{
name: 'invalid width',
mutate: raw => Reflect.set(raw, 'width', 0),
code: 64
},
{
name: 'non-integer map value',
mutate: raw => Reflect.set(raw.map, '0', [1, 1, 1.5, 1]),
code: 64
},
{
name: 'non-string layer alias',
mutate: raw => Reflect.set(raw.layerAlias, '0', 3),
code: 64
},
{
name: 'out of range event position',
mutate: raw => Reflect.set(raw.events[0], '4', { 5: 'event' }),
code: 64
},
{
name: 'non-string event id',
mutate: raw => Reflect.set(raw.events[0][1], '5', 3),
code: 64
},
{
name: 'event layer without map layer',
mutate: raw => Reflect.set(raw.events, '9', {}),
code: 64
}
];
it.each(cases)('$name is rejected with code $code', testCase => {
const mapState = createFixture();
const raw = createRaw();
testCase.mutate(raw);
const result = logger.catch(() => mapState.fromRaw(raw));
expect(result.ret).toBeNull();
expect(result.info.map(info => info.code)).toContain(testCase.code);
expect(mapState.getMap('F1')).toBeNull();
});
});
describe('MapState activation and areas', () => {
// 验证激活状态切换、getActiveMap 与三类迭代器
it('toggles activation and iterates active and inactive floors', () => {
const mapState = createFixture();
const first = mapState.createMap('F1', 2, 2);
const second = mapState.createMap('F2', 2, 2);
mapState.setMapActiveStatus('F1', true);
expect(mapState.isMapActive('F1')).toBe(true);
expect(mapState.getActiveMap('F1')).toBe(first);
expect(mapState.getActiveMap('F2')).toBeNull();
expect([...mapState.iterateActiveMaps()]).toEqual([['F1', first]]);
expect([...mapState.iterateInactiveMaps()]).toEqual([['F2', second]]);
expect([...mapState.iterateAllMaps()]).toEqual([
['F1', first],
['F2', second]
]);
mapState.setMapActiveStatus('unknown', true);
expect(mapState.isMapActive('unknown')).toBe(false);
});
// 验证按分区批量激活与取消激活
it('activates and deactivates every floor of an area', () => {
const mapState = createFixture();
mapState.createMap('A', 2, 2);
mapState.createMap('B', 2, 2);
mapState.createMap('C', 2, 2);
mapState.createMap('D', 2, 2);
mapState.setMapList(['A', 'B', 'C', 'D']);
mapState.setArea(new Set([[{ start: 0, end: 1 }]]));
mapState.activeArea('B');
expect(mapState.isMapActive('A')).toBe(true);
expect(mapState.isMapActive('B')).toBe(true);
expect(mapState.isMapActive('C')).toBe(false);
mapState.deactiveArea('A');
expect(mapState.isMapActive('A')).toBe(false);
expect(mapState.isMapActive('B')).toBe(false);
mapState.activeArea('unknown');
expect(mapState.isMapActive('C')).toBe(false);
});
// 验证自动分区激活器进入新分区时切换激活集
it('switches the active area when the automator enters another floor', () => {
const mapState = createFixture();
mapState.createMap('A', 2, 2);
mapState.createMap('B', 2, 2);
mapState.setMapList(['A', 'B']);
mapState.setArea(
new Set([[{ start: 0, end: 0 }], [{ start: 1, end: 1 }]])
);
mapState.notifyEnterFloor('A');
expect(mapState.isMapActive('A')).toBe(false);
mapState.useAutoActivitor(true);
mapState.notifyEnterFloor('A');
expect(mapState.isMapActive('A')).toBe(true);
mapState.notifyEnterFloor('B');
expect(mapState.isMapActive('A')).toBe(false);
expect(mapState.isMapActive('B')).toBe(true);
mapState.useAutoActivitor(false);
mapState.notifyEnterFloor('A');
expect(mapState.isMapActive('A')).toBe(false);
});
});
describe('MapState reference comparison', () => {
// 验证 compareWith 对缺失楼层标记为脏,且对已有楼层按参考数组判定
it('marks missing floors dirty and compares registered floors', () => {
const mapState = createFixture();
const map = mapState.fromRaw(createRaw())!;
mapState.compareWith(new Map());
expect(map.dirty()).toBe(true);
const compared = createFixture();
compared.fromRaw(createRaw());
compared.compareWith(
new Map([['F1', new Map([[0, new Uint32Array([1, 2, 1, 2])]])]])
);
expect(compared.getMap('F1')!.dirty()).toBe(false);
});
// 验证 compareWith 只生效一次,之后创建的新楼层直接视为全脏
it('applies the reference once and marks later floors dirty', () => {
const mapState = createFixture();
mapState.compareWith(new Map());
const late = mapState.createMap('F9', 2, 2);
expect(late.dirty()).toBe(true);
});
// 验证不活跃楼层不会被活跃判定选中,供通行谓词侧读取事件层
it('exposes the active floor event layer for pass predicates', () => {
const mapState = createFixture();
const map = mapState.fromRaw(createRaw())!;
const activeLayer = () =>
mapState.getActiveMap('F1')?.eventLayer ?? null;
expect(activeLayer()).toBeNull();
mapState.setMapActiveStatus('F1', true);
expect(activeLayer()).toBe(map.eventLayer);
});
});