From 7012aa49fce154f2fc48c1da3b1719229c0fd709 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 14 Sep 2026 14:39:41 +0800 Subject: [PATCH] test(06-03): cover EnemyManager registry, prefab, reuse and dirty (stage 3) - registry/attribute defaults incl. error 53 for non-serializable values - prefab add/get/create/delete/change and reuse mapping reads - modifyPrefabAttribute callback and index rebuild - comparer lifecycle: warn 117 on repeat compareWith, warn 118 without comparer - skip #06-03-1 (createEnemy does not resolve reuse mapping) per D-05 --- .../data-base/src/enemy/manager.test.ts | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 packages-user/data-base/src/enemy/manager.test.ts diff --git a/packages-user/data-base/src/enemy/manager.test.ts b/packages-user/data-base/src/enemy/manager.test.ts new file mode 100644 index 0000000..82c30dc --- /dev/null +++ b/packages-user/data-base/src/enemy/manager.test.ts @@ -0,0 +1,414 @@ +// 测试 EnemyManager 的注册表、模板增删改查、复用映射与比较器脏跟踪 +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + type IEnemy, + type IEnemyComparer, + type IEnemyLegacyBridge, + type IEnemyManager +} from './types'; + +vi.hoisted(() => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + 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; + }; +}); + +interface TestModules { + Enemy: typeof import('./enemy').Enemy; + EnemyManager: typeof import('./manager').EnemyManager; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const enemyModule = await import('./enemy'); + const managerModule = await import('./manager'); + const commonModule = await import('@motajs/common'); + modules = { + Enemy: enemyModule.Enemy, + EnemyManager: managerModule.EnemyManager, + logger: commonModule.logger + }; +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +interface IEnemyTestAttr { + hp: number; + atk: number; + tags: string[]; +} + +/** 仅作为 EnemyManager 必需协作对象的内联假桥接,方法不会被本计划调用 */ +const bridge: IEnemyLegacyBridge = { + fromLegacyEnemy: (_enemy, defaultValue) => ({ + hp: 10, + atk: 2, + tags: ['legacy'], + ...defaultValue + }) +}; + +/** + * 构造一个独立的怪物管理器实例 + */ +function createManager(): IEnemyManager { + return new modules.EnemyManager(bridge); +} + +/** + * 构造一个带合成属性的怪物模板 + * @param code 怪物图块数字 + * @param id 怪物标识符 + * @param attributes 覆盖的怪物属性 + */ +function createPrefab( + code: number, + id: string, + attributes: Partial = {} +): IEnemy { + return new modules.Enemy(id, code, { + hp: 20, + atk: 8, + tags: ['prefab'], + ...attributes + }); +} + +/** + * 构造一个只按生命值判定相等的内联怪物比较器 + */ +function createHpComparer(): IEnemyComparer { + return { + compare: (enemyA, enemyB) => + enemyA.getAttribute('hp') === enemyB.getAttribute('hp') + }; +} + +/** + * 读取管理器内部的脏模板集合,仅用于观察脏跟踪结果 + * @param manager 怪物管理器 + */ +function dirtyCodes(manager: IEnemyManager): number[] { + const dirty = Reflect.get(manager, 'dirtySet') as Set; + return [...dirty].sort((a, b) => a - b); +} + +describe('EnemyManager registry and attribute defaults', () => { + // 验证注册与重复注册特殊属性创建函数均不会产生告警或报错 + it('accepts special creators and allows re-registration silently', () => { + const manager = createManager(); + const creator = () => { + throw new Error('special creator should not be invoked here'); + }; + + const result = modules.logger.catch(() => { + manager.registerSpecial(6, creator); + manager.registerSpecial(6, creator); + }); + + expect(result.info).toEqual([]); + }); + + // 验证合法的属性默认值注册不产生报错 + it('accepts serializable default attribute values', () => { + const manager = createManager(); + + const result = modules.logger.catch(() => { + manager.setAttributeDefaults('hp', 0); + manager.setAttributeDefaults('tags', []); + }); + + expect(result.info).toEqual([]); + }); + + // 验证函数/符号/bigint/undefined 默认值均报错 53 + it('logs error 53 for non-serializable default attribute values', () => { + const manager = createManager(); + + const result = modules.logger.catch(() => { + manager.setAttributeDefaults('hp', (() => 1) as never); + manager.setAttributeDefaults('atk', Symbol('bad') as never); + manager.setAttributeDefaults('hp', 1n as never); + manager.setAttributeDefaults('atk', undefined as never); + }); + + expect(result.info.filter(info => info.code === 53)).toHaveLength(4); + }); +}); + +describe('EnemyManager prefab CRUD', () => { + // 验证 addPrefab 存储克隆且同时可按 code 与按 id 查询 + it('adds a prefab clone addressable by code and id', () => { + const manager = createManager(); + const source = createPrefab(1, 'slime'); + + manager.addPrefab(source); + + const byCode = manager.getPrefab(1); + const byId = manager.getPrefabById('slime'); + + expect(byCode).not.toBeNull(); + expect(byId).toBe(byCode); + expect(byCode).not.toBe(source); + + source.setAttribute('hp', 999); + + expect(manager.getPrefab(1)!.getAttribute('hp')).toBe(20); + }); + + // 验证 code 或 id 已存在时 addPrefab 不做任何操作 + it('ignores a prefab whose code or id already exists', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.addPrefab(createPrefab(1, 'other')); + manager.addPrefab(createPrefab(2, 'slime')); + + expect(manager.getPrefab(1)!.id).toBe('slime'); + expect(manager.getPrefabById('slime')!.code).toBe(1); + expect(manager.getPrefab(2)).toBeNull(); + expect(manager.getPrefabById('other')).toBeNull(); + }); + + // 验证 createEnemy 与 createEnemyById 返回互相独立、与模板独立的克隆,未知返回 null + it('creates independent enemy clones by code and id', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + const first = manager.createEnemy(1); + const second = manager.createEnemyById('slime'); + + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + expect(first).not.toBe(second); + + first!.setAttribute('hp', 99); + + expect(manager.createEnemy(1)!.getAttribute('hp')).toBe(20); + expect(manager.getPrefab(1)!.getAttribute('hp')).toBe(20); + expect(manager.createEnemy(99)).toBeNull(); + expect(manager.createEnemyById('missing')).toBeNull(); + }); + + // 验证 deletePrefab 按 code 与按 id 都会移除两个索引 + it('deletes a prefab from both indexes by code or id', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + manager.addPrefab(createPrefab(2, 'bat')); + + manager.deletePrefab(1); + + expect(manager.getPrefab(1)).toBeNull(); + expect(manager.getPrefabById('slime')).toBeNull(); + expect(manager.createEnemy(1)).toBeNull(); + expect(manager.getPrefab(2)).not.toBeNull(); + + manager.deletePrefab('bat'); + + expect(manager.getPrefab(2)).toBeNull(); + expect(manager.getPrefabById('bat')).toBeNull(); + }); + + // 验证 changePrefab 替换模板,并在 code 或 id 变化时重建索引 + it('replaces a prefab and reindexes when its code or id changes', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.changePrefab(1, createPrefab(1, 'slime', { hp: 50 })); + + expect(manager.getPrefab(1)!.getAttribute('hp')).toBe(50); + + manager.changePrefab(1, createPrefab(2, 'bat', { hp: 70 })); + + expect(manager.getPrefab(1)).toBeNull(); + expect(manager.getPrefabById('slime')).toBeNull(); + expect(manager.getPrefab(2)!.getAttribute('hp')).toBe(70); + expect(manager.getPrefabById('bat')).not.toBeNull(); + }); +}); + +describe('EnemyManager reuse mapping', () => { + // 验证复用注册后按复用 code 与复用 id 读取都解析到来源模板 + it('resolves reused codes and ids to the source prefab on reads', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.reusePrefab(1, 100, 'slime-reuse'); + + expect(manager.getPrefab(100)).toBe(manager.getPrefab(1)); + expect(manager.getPrefabById('slime-reuse')).toBe( + manager.getPrefabById('slime') + ); + }); + + // 验证来源不存在时复用注册不产生任何映射 + it('ignores reuse registration for an unknown source', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.reusePrefab(999, 100, 'missing-reuse'); + + expect(manager.getPrefab(100)).toBeNull(); + expect(manager.getPrefabById('missing-reuse')).toBeNull(); + }); + + // 疑似 bug:createEnemy/createEnemyById 未走复用映射,详见 06-TEST-FINDINGS.md #06-03-1,修复后取消 skip + it.skip('creates enemies for reused codes and ids through the reuse mapping', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + manager.reusePrefab(1, 100, 'slime-reuse'); + + expect(manager.createEnemy(100)!.id).toBe('slime'); + expect(manager.createEnemyById('slime-reuse')!.id).toBe('slime'); + }); +}); + +describe('EnemyManager modifyPrefabAttribute', () => { + // 验证修改回调按 code 应用到已有模板 + it('applies a modification callback to an existing prefab', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.modifyPrefabAttribute(1, prefab => { + prefab.setAttribute('hp', 30); + return prefab; + }); + + expect(manager.getPrefab(1)!.getAttribute('hp')).toBe(30); + }); + + // 验证回调返回新对象且 code 或 id 变化时重建两个索引 + it('rebuilds the indexes when a modification changes code or id', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime')); + + manager.modifyPrefabAttribute(1, () => + createPrefab(2, 'bat', { hp: 40 }) + ); + + expect(manager.getPrefab(1)).toBeNull(); + expect(manager.getPrefabById('slime')).toBeNull(); + expect(manager.getPrefab(2)!.getAttribute('hp')).toBe(40); + expect(manager.getPrefabById('bat')).not.toBeNull(); + }); + + // 验证未知 code 的修改回调不会被调用 + it('ignores a modification for an unknown prefab', () => { + const manager = createManager(); + let called = false; + + manager.modifyPrefabAttribute(99, prefab => { + called = true; + return prefab; + }); + + expect(called).toBe(false); + }); +}); + +describe('EnemyManager comparer and dirty tracking', () => { + // 验证未附加比较器时 getEnemyComparer 返回 null,附加后返回同一对象 + it('reports the attached comparer and defaults to null', () => { + const manager = createManager(); + + expect(manager.getEnemyComparer()).toBeNull(); + + const comparer = createHpComparer(); + manager.attachEnemyComparer(comparer); + + expect(manager.getEnemyComparer()).toBe(comparer); + }); + + // 验证首次 compareWith 之前脏跟踪惰性,之后按比较结果标记与清除 + it('keeps dirty tracking lazy until compareWith then tracks changes', () => { + const manager = createManager(); + manager.attachEnemyComparer(createHpComparer()); + manager.addPrefab(createPrefab(1, 'slime', { hp: 20 })); + + manager.modifyPrefabAttribute(1, prefab => { + prefab.setAttribute('hp', 30); + return prefab; + }); + + expect(dirtyCodes(manager)).toEqual([]); + + manager.compareWith( + new Map([[1, createPrefab(1, 'slime', { hp: 20 })]]) + ); + + expect(dirtyCodes(manager)).toEqual([]); + + manager.modifyPrefabAttribute(1, prefab => { + prefab.setAttribute('hp', 40); + return prefab; + }); + + expect(dirtyCodes(manager)).toEqual([1]); + + manager.modifyPrefabAttribute(1, prefab => { + prefab.setAttribute('hp', 20); + return prefab; + }); + + expect(dirtyCodes(manager)).toEqual([]); + }); + + // 验证第二次 compareWith 告警 117 并刷新脏集丢弃与参考相等的条目 + it('warns 117 on a repeat compareWith and refreshes the dirty set', () => { + const manager = createManager(); + let equal = false; + manager.attachEnemyComparer({ + compare: () => equal + }); + manager.addPrefab(createPrefab(1, 'slime')); + manager.compareWith(new Map([[1, createPrefab(1, 'slime')]])); + + manager.changePrefab(1, createPrefab(1, 'slime', { hp: 30 })); + + expect(dirtyCodes(manager)).toEqual([1]); + + equal = true; + + const result = modules.logger.catch(() => + manager.compareWith(new Map([[1, createPrefab(1, 'slime')]])) + ); + + expect(result.info.map(info => info.code)).toContain(117); + expect(dirtyCodes(manager)).toEqual([]); + }); + + // 验证未附加比较器时 updateDirty 告警 118 并标记为脏 + it('warns 118 and marks dirty when no comparer is attached', () => { + const manager = createManager(); + manager.addPrefab(createPrefab(1, 'slime', { hp: 20 })); + manager.compareWith( + new Map([[1, createPrefab(1, 'slime', { hp: 20 })]]) + ); + + const result = modules.logger.catch(() => + manager.modifyPrefabAttribute(1, prefab => { + prefab.setAttribute('hp', 30); + return prefab; + }) + ); + + expect(result.info.map(info => info.code)).toContain(118); + expect(dirtyCodes(manager)).toEqual([1]); + }); +});