template/packages-user/data-base/src/map/mapLayer.test.ts
unanmed 309f1b1850 test(06-11): cover static-dynamic-move-static full chain (G-06-06-A)
- mapLayer.test.ts: both keepEvent branches verified after a real-timer move,
  with exact event maps, dirty flags, block write-back and dynamic index updates
- 06-COVERAGE-MAP.md: append the 06-11 gap-fill section (create-or-append)
2026-09-15 10:27:59 +08:00

671 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 测试 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);
});
// 验证静态→动态→移动→静态全链路的 keepEvent=true 分支保留移动后的动态专属事件
it('keeps the moved dynamic events across a full static round trip', async () => {
const { layer } = createFixture([1, 2, 1, 2]);
layer.getTile(0, 0);
const tile = layer.transferToDynamic(0, 0)!;
const controller = tile.step(FaceDirection.Right);
expect(controller).not.toBeNull();
expect(tile.mover.moving).toBe(true);
await controller!.onEnd;
expect(tile.x).toBe(1);
expect(tile.y).toBe(0);
expect([...layer.getDynamicTilesAt(1, 0)]).toContain(tile);
expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]);
expect(layer.getBlock(1, 0)).toBe(2);
tile.tileEvent().set(30, 'moved-event');
const result = logger.catch(() => layer.transferToStatic(tile, true)!);
const restored = result.ret;
expect(restored.num()).toBe(1);
expect(layer.getBlock(1, 0)).toBe(1);
expect(layer.getBlock(0, 0)).toBe(0);
expect(restored.tileEvent().get()).toEqual(
new Map([
[10, 'base-event'],
[30, 'moved-event']
])
);
expect(restored.tileEvent().dirty()).toBe(true);
expect([...layer.getDynamicTilesAt(1, 0)]).toEqual([]);
});
// 验证移动后转静态的 keepEvent=false 分支丢弃动态专属事件并按写回块 num 重推默认事件
it('drops the moved dynamic events across a full static round trip', async () => {
const { layer } = createFixture([1, 2, 1, 2]);
layer.getTile(0, 0);
const tile = layer.transferToDynamic(0, 0)!;
const controller = tile.step(FaceDirection.Right);
expect(controller).not.toBeNull();
await controller!.onEnd;
expect(tile.x).toBe(1);
expect(tile.y).toBe(0);
expect([...layer.getDynamicTilesAt(1, 0)]).toContain(tile);
expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]);
expect(layer.getBlock(1, 0)).toBe(2);
tile.tileEvent().set(30, 'moved-event');
const result = logger.catch(() => layer.transferToStatic(tile, false)!);
const restored = result.ret;
expect(restored.num()).toBe(1);
expect(layer.getBlock(1, 0)).toBe(1);
expect(layer.getBlock(0, 0)).toBe(0);
expect(restored.tileEvent().get()).toEqual(
new Map([[10, 'base-event']])
);
expect(restored.tileEvent().get().has(30)).toBe(false);
expect(restored.tileEvent().dirty()).toBe(false);
expect([...layer.getDynamicTilesAt(1, 0)]).toEqual([]);
});
// 验证 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]]);
});
});