diff --git a/packages-user/data-base/src/map/dynamicTile.test.ts b/packages-user/data-base/src/map/dynamicTile.test.ts new file mode 100644 index 0000000..dc03424 --- /dev/null +++ b/packages-user/data-base/src/map/dynamicTile.test.ts @@ -0,0 +1,231 @@ +// 测试 DynamicTile 的创建告警、数字与原始数据、位置、朝向、转换和删除 +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 IResizableMapLayer } from './types'; +import { MapState } from './mapState'; +import { DynamicTile } from './dynamicTile'; + +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 DynamicFixture { + layer: IResizableMapLayer; + roleFace: IRoleFaceBinder; +} + +/** 构造一个含单图层的小地图,作为动态图块实例的宿主 */ +function createFixture(blocks: number[] = [0, 0, 0, 0]): DynamicFixture { + 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 { + layer: map.getLayerByAlias('event')! as IResizableMapLayer, + roleFace + }; +} + +describe('DynamicTile model', () => { + // 验证构造时缺少图块原始数据会告警 143 且 raw 为空 + it('warns code 143 when the raw tile data is missing', () => { + const { layer } = createFixture(); + + const result = logger.catch(() => new DynamicTile(99, 0, 0, layer)); + + expect(result.info.map(info => info.code)).toContain(143); + expect(result.ret.num()).toBe(99); + expect(result.ret.raw()).toBeNull(); + }); + + // 验证 num 与 raw 跟随图块存储,set 到未知数字时告警 143 + it('reflects num and raw and warns 143 on an unknown set', () => { + const { layer } = createFixture(); + const tile = layer.createDynamic(1, 0, 0); + + expect(tile.num()).toBe(1); + expect(tile.raw()?.id).toBe('base'); + + const result = logger.catch(() => tile.set(99)); + expect(result.info.map(info => info.code)).toContain(143); + expect(tile.num()).toBe(99); + expect(tile.raw()).toBeNull(); + + tile.set(2); + expect(tile.num()).toBe(2); + expect(tile.raw()?.id).toBe('alternate'); + expect(tile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(tile.tileEvent().dirty()).toBe(false); + }); + + // 验证 setPos 更新坐标并同步图层索引 + it('updates the position and the layer index through setPos', () => { + const { layer } = createFixture(); + const tile = layer.createDynamic(1, 0, 0); + + tile.setPos(1, 1); + + expect(tile.x).toBe(1); + expect(tile.y).toBe(1); + expect([...layer.getDynamicTilesAt(1, 1)]).toContain(tile); + expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]); + }); + + // 验证 getCurrentFaceDirection 依据朝向绑定返回 Unknown 或已绑定朝向 + it('reads the current face direction from the binder', () => { + const { layer, roleFace } = createFixture(); + const tile = layer.createDynamic(1, 0, 0); + + expect(tile.getCurrentFaceDirection()).toBe(FaceDirection.Unknown); + + roleFace.malloc(1, FaceDirection.Down); + expect(tile.getCurrentFaceDirection()).toBe(FaceDirection.Down); + + roleFace.bind(2, 1, FaceDirection.Right); + tile.setFaceDirection(FaceDirection.Right); + expect(tile.getCurrentFaceDirection()).toBe(FaceDirection.Right); + }); + + // 验证 toStatic 把动态图块写回静态图层并移出动态索引 + it('converts back to a static tile and removes it from the dynamic index', () => { + const { layer } = createFixture(); + const tile = layer.createDynamic(1, 0, 0); + + const restored = tile.toStatic(); + + expect(restored?.num()).toBe(1); + expect(layer.getBlock(0, 0)).toBe(1); + expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]); + }); + + // 验证 toStatic 覆盖已有静态图块时告警 129 + it('warns code 129 when toStatic overwrites an existing static tile', () => { + const { layer } = createFixture([2, 0, 0, 0]); + const tile = layer.createDynamic(1, 0, 0); + + const result = logger.catch(() => tile.toStatic()); + + expect(result.info.map(info => info.code)).toContain(129); + expect(result.ret?.num()).toBe(1); + }); + + // 验证 toStaticIfSafe 在目标点被占用时拒绝转换 + it('refuses toStaticIfSafe when the target cell is occupied', () => { + const { layer } = createFixture([2, 0, 0, 0]); + const tile = layer.createDynamic(1, 0, 0); + + expect(tile.toStaticIfSafe()).toBeNull(); + expect(layer.getBlock(0, 0)).toBe(2); + expect([...layer.getDynamicTilesAt(0, 0)]).toContain(tile); + }); + + // 验证 step 返回控制器并在真实计时器下完成移动 + it('steps through the mover and resolves the controller', async () => { + const { layer } = createFixture(); + const tile = layer.createDynamic(1, 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); + }); + + // 验证 delete 异步删除动态图块并清空动态索引 + it('deletes the dynamic tile and clears the index', async () => { + const { layer } = createFixture(); + const tile = layer.createDynamic(1, 0, 0); + + await tile.delete(); + + expect([...layer.getDynamicTilesAt(0, 0)]).toEqual([]); + expect([...layer.iterateDynamicTiles()]).toEqual([]); + }); +}); diff --git a/packages-user/data-base/src/map/eventView.test.ts b/packages-user/data-base/src/map/eventView.test.ts index fc0f5cb..35a87fb 100644 --- a/packages-user/data-base/src/map/eventView.test.ts +++ b/packages-user/data-base/src/map/eventView.test.ts @@ -1,5 +1,6 @@ // 测试 LayerEventView 的参考基准、dirty 状态和恢复行为 -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { logger } from '@motajs/common'; import { LayerEventView } from './eventView'; vi.hoisted(() => { @@ -7,6 +8,10 @@ vi.hoisted(() => { vi.stubGlobal('location', { origin: 'http://localhost' }); }); +afterAll(() => { + vi.unstubAllGlobals(); +}); + describe('LayerEventView reference tracking', () => { // 验证新增、删除和清空事件时 dirty 状态能随参考基准正确变化 it('keeps dirty state in sync with set, delete, and clear', () => { @@ -49,3 +54,67 @@ describe('LayerEventView reference tracking', () => { expect(view.dirty()).toBe(false); }); }); + +describe('LayerEventView per-priority dirty tracking', () => { + // 验证多个优先级的脏条目按优先级独立增减,互不影响 + it('tracks dirty entries of different priorities independently', () => { + const view = new LayerEventView(); + view.set(10, 'alpha'); + view.set(20, 'beta'); + view.markPure(); + expect(view.dirty()).toBe(false); + + view.set(10, 'changed'); + expect(view.dirty()).toBe(true); + view.set(10, 'alpha'); + expect(view.dirty()).toBe(false); + + view.set(30, 'gamma'); + view.set(40, 'delta'); + expect(view.dirty()).toBe(true); + view.delete(30); + expect(view.dirty()).toBe(true); + view.delete(40); + expect(view.dirty()).toBe(false); + }); + + // 验证 clear 后逐条恢复参考基准才重新变回 clean + it('clears every priority and only becomes clean after restoring all', () => { + const view = new LayerEventView(); + view.set(10, 'alpha'); + view.set(20, 'beta'); + view.markPure(); + + view.clear(); + expect(view.get()).toEqual(new Map()); + expect(view.dirty()).toBe(true); + + view.set(10, 'alpha'); + expect(view.dirty()).toBe(true); + view.set(20, 'beta'); + expect(view.dirty()).toBe(false); + }); + + // 验证删除不存在的优先级不会改变脏状态 + it('ignores deleting an unknown priority', () => { + const view = new LayerEventView(); + view.set(10, 'alpha'); + view.markPure(); + + view.delete(99); + + expect(view.get()).toEqual(new Map([[10, 'alpha']])); + expect(view.dirty()).toBe(false); + }); + + // 验证重复设置同一优先级会告警 136 并覆盖为最新值 + it('warns code 136 when the same priority is set twice', () => { + const view = new LayerEventView(); + view.set(10, 'first'); + + const result = logger.catch(() => view.set(10, 'second')); + + expect(result.info.map(info => info.code)).toContain(136); + expect(view.get()).toEqual(new Map([[10, 'second']])); + }); +}); diff --git a/packages-user/data-base/src/map/staticTile.test.ts b/packages-user/data-base/src/map/staticTile.test.ts new file mode 100644 index 0000000..b4d2954 --- /dev/null +++ b/packages-user/data-base/src/map/staticTile.test.ts @@ -0,0 +1,169 @@ +// 测试 StaticTile 的图块数字、原始数据、设置、默认事件与转换为动态图块 +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 } from '@motajs/common'; +import { type IResizableMapLayer } from './types'; +import { MapState } from './mapState'; +import { StaticTile } from './staticTile'; + +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 + }; +} + +/** 构造一个含单图层的小地图,作为静态图块实例的宿主 */ +function createFixture(blocks: number[] = [1, 2, 1, 2]): IResizableMapLayer { + 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; + const mapState = new MapState(tileStore, state); + const map = mapState.fromRaw({ + floorId: 'F1', + width: 2, + map: { 0: blocks }, + layerAlias: { 0: 'event' }, + events: { 0: {} } + })!; + return map.getLayerByAlias('event')! as IResizableMapLayer; +} + +describe('StaticTile model', () => { + // 验证 num 与 raw 跟随图层图块与图块存储,未知数字返回空原始数据 + it('reports num and raw from the layer and the tile store', () => { + const layer = createFixture(); + const tile = layer.getTile(0, 0)!; + + expect(tile.num()).toBe(1); + expect(tile.raw()?.id).toBe('base'); + expect(tile.raw()?.num).toBe(1); + + tile.set(2); + expect(tile.num()).toBe(2); + expect(tile.raw()?.id).toBe('alternate'); + + tile.set(99); + expect(tile.num()).toBe(99); + expect(tile.raw()).toBeNull(); + }); + + // 验证 set 写入图层并恢复新图块默认事件、标记为纯基准 + it('writes the layer and restores default events on set', () => { + const layer = createFixture(); + const tile = layer.getTile(0, 0)!; + + tile.set(2); + + expect(layer.getBlock(0, 0)).toBe(2); + expect(tile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(tile.tileEvent().dirty()).toBe(false); + }); + + // 验证 shouldSave 仅在事件视图偏离默认基准时为真 + it('only reports shouldSave when the tile events deviate', () => { + const layer = createFixture(); + const tile = layer.getTile(0, 0)!; + + expect(tile.shouldSave()).toBe(false); + + tile.tileEvent().set(30, 'runtime-event'); + expect(tile.shouldSave()).toBe(true); + + tile.tileEvent().delete(30); + expect(tile.shouldSave()).toBe(false); + }); + + // 验证 toDynamic 清空静态图块并生成继承默认事件的动态图块 + it('converts to a dynamic tile and clears the static block', () => { + const layer = createFixture(); + const tile = layer.getTile(0, 0)!; + + const dynamic = tile.toDynamic(); + + expect(tile.num()).toBe(0); + expect(layer.getBlock(0, 0)).toBe(0); + expect(dynamic.num()).toBe(1); + expect(dynamic.tileEvent().get()).toEqual( + new Map([[10, 'base-event']]) + ); + }); + + // 验证直接构造越图静态图块时 num 为 -1 且 raw 为空 + it('reports an out-of-map tile as num -1 with no raw data', () => { + const layer = createFixture(); + const outside = new StaticTile(9, 9, layer); + + expect(outside.num()).toBe(-1); + expect(outside.raw()).toBeNull(); + }); +}); diff --git a/packages-user/data-base/src/map/tile.test.ts b/packages-user/data-base/src/map/tile.test.ts new file mode 100644 index 0000000..f3f19c9 --- /dev/null +++ b/packages-user/data-base/src/map/tile.test.ts @@ -0,0 +1,197 @@ +// 测试 MapTileBase 经静态/动态图块呈现的图块数字、原始数据、默认事件和点位事件联动 +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 } from '@motajs/common'; +import { type IResizableMapLayer } from './types'; +import { MapState } from './mapState'; +import { StaticTile } from './staticTile'; + +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 TileFixture { + layer: IResizableMapLayer; + roleFace: IRoleFaceBinder; +} + +/** 构造一个含单图层的小地图,作为图块实例的宿主 */ +function createFixture(blocks: number[] = [2, 1, 1, 2]): TileFixture { + 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 { + layer: map.getLayerByAlias('event')! as IResizableMapLayer, + roleFace + }; +} + +describe('MapTileBase tile identity', () => { + // 验证 num/raw/set 始终与图块存储中的数据保持一致 + it('reflects the tile store through num, raw and set', () => { + const { layer } = createFixture(); + const tile = layer.getTile(0, 0)!; + + expect(tile.num()).toBe(2); + expect(tile.raw()?.id).toBe('alternate'); + + tile.set(1); + expect(tile.num()).toBe(1); + expect(tile.raw()?.id).toBe('base'); + expect(tile.raw()?.num).toBe(1); + }); + + // 验证 set 切换图块后恢复该图块默认事件并把事件视图标回纯基准 + it('restores the default events and marks the view pure after set', () => { + const { layer } = createFixture(); + const tile = layer.getTile(0, 0)!; + + expect(tile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(tile.tileEvent().dirty()).toBe(false); + + tile.set(1); + expect(tile.tileEvent().get()).toEqual(new Map([[10, 'base-event']])); + expect(tile.tileEvent().dirty()).toBe(false); + }); + + // 验证每个图块实例持有独立的图块事件视图 + it('keeps a tile event view per tile instance', () => { + const { layer } = createFixture(); + const first = layer.getTile(0, 0)!; + const second = layer.getTile(1, 0)!; + + first.tileEvent().set(30, 'first-only'); + + expect(second.tileEvent().get()).toEqual(new Map([[10, 'base-event']])); + expect(second.tileEvent().dirty()).toBe(false); + }); + + // 验证 pointEvent 返回该图块坐标的层点位视图,越图时返回 null + it('delegates pointEvent to the layer view and returns null outside the map', () => { + const { layer } = createFixture(); + const tile = layer.getTile(0, 0)!; + + expect(tile.pointEvent()).toBe(layer.event(0, 0)); + layer.event(0, 0)!.set(5, 'point-event'); + expect(tile.pointEvent()!.get()).toEqual(new Map([[5, 'point-event']])); + + const outside = new StaticTile(5, 5, layer); + expect(outside.pointEvent()).toBeNull(); + }); + + // 验证 setFaceDirection 经朝向绑定器映射并返回映射后的图块数字 + it('maps the face direction through the binder and returns the new num', () => { + const { layer, roleFace } = createFixture([1, 1, 1, 1]); + roleFace.malloc(1, FaceDirection.Down); + roleFace.bind(2, 1, FaceDirection.Right); + const tile = layer.getTile(0, 0)!; + + expect(tile.setFaceDirection(FaceDirection.Right)).toBe(2); + expect(tile.num()).toBe(2); + + expect(tile.setFaceDirection(FaceDirection.Up)).toBe(2); + expect(tile.num()).toBe(2); + }); +}); + +describe('MapTileBase on dynamic tiles', () => { + // 验证动态图块的 num/raw/set 与设置后默认事件恢复和静态图块一致 + it('reflects num, raw and default events on a dynamic tile', () => { + const { layer } = createFixture([0, 0, 0, 0]); + const tile = layer.createDynamic(1, 0, 0); + + expect(tile.num()).toBe(1); + expect(tile.raw()?.id).toBe('base'); + + tile.set(2); + expect(tile.num()).toBe(2); + expect(tile.raw()?.id).toBe('alternate'); + expect(tile.tileEvent().get()).toEqual( + new Map([[20, 'alternate-event']]) + ); + expect(tile.tileEvent().dirty()).toBe(false); + }); +});