From b971e145e987f6b702dbf4bf920c7fab3cfd4a9e Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 14 Sep 2026 16:26:10 +0800 Subject: [PATCH] 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 --- .../data-base/src/map/gameMap.test.ts | 254 ++++++++ .../data-base/src/map/mapLayer.test.ts | 603 ++++++++++++++++++ .../data-base/src/map/mapState.test.ts | 356 +++++++++++ 3 files changed, 1213 insertions(+) create mode 100644 packages-user/data-base/src/map/gameMap.test.ts create mode 100644 packages-user/data-base/src/map/mapLayer.test.ts create mode 100644 packages-user/data-base/src/map/mapState.test.ts diff --git a/packages-user/data-base/src/map/gameMap.test.ts b/packages-user/data-base/src/map/gameMap.test.ts new file mode 100644 index 0000000..bbf67ee --- /dev/null +++ b/packages-user/data-base/src/map/gameMap.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 = {} +): 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); + }); +}); diff --git a/packages-user/data-base/src/map/mapLayer.test.ts b/packages-user/data-base/src/map/mapLayer.test.ts new file mode 100644 index 0000000..f4fec8b --- /dev/null +++ b/packages-user/data-base/src/map/mapLayer.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 = {} +): 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-1:transferToDynamic 越图当前复用码 131(setEventLayer 专属码), + // 正确预期应为与 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]]); + }); +}); diff --git a/packages-user/data-base/src/map/mapState.test.ts b/packages-user/data-base/src/map/mapState.test.ts new file mode 100644 index 0000000..7a87932 --- /dev/null +++ b/packages-user/data-base/src/map/mapState.test.ts @@ -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 ( + this: Map, + 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 ( + this: Map, + 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 = {} +): 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); + }); +});