diff --git a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md index 215636d..b3d3840 100644 --- a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md +++ b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md @@ -24,3 +24,4 @@ | 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 skip 用例 | 严重度 | | --- | --- | --- | --- | --- | --- | --- | --- | | `damage.ts` `DamageContext.calculateCritical` / `findNextCritical` | 产出的临界点 `info` 与 `nextValue` 不对应 | 假计算器 `damage = 100 - atk * 10`、`getCriticalLimit = 10`、当前 `atk = 0`;首个临界点 `nextValue = 1`(正确伤害应为 90),但 `info.damage = 0`、`damageDiff = -100` | `findNextCritical` 在 `middleInfo.damage < referenceDamage` 分支只更新 `right`,从不更新 `targetInfo`,`targetInfo` 一直停留在初始 `upperLimit` 处的计算结果 | 临界点查询(数值提示、战斗模拟)展示的伤害与对应属性值不匹配 | 在 `middleInfo.damage < referenceDamage` 分支同步 `targetInfo = middleInfo`,或二分结束后按最终 `right` 重新计算一次 | `damage.test.ts` `reports the damage info matching the yielded critical value`(#06-01-1) | 中 | +| `mapDamage.ts` `MapDamage.deleteEnemy` | 删除怪物后,该怪物产生的有来源地图伤害仍然存在 | 注册一个怪物视图并转换出伤害(假视图伤害 7)→ `getSeparatedDamage` 有 1 条 → `deleteEnemy(view)` → `getSeparatedDamage` 仍有 1 条(正确应为 0) | `viewStore` 与 `damageStore` 只在读/删路径出现,全文件没有任何 `.set(...)` 写入,因此 `deleteEnemy` / `removeEnemyAffecting` 中 `viewStore.get(viewItem)` 恒为 `undefined`,清理循环被整体跳过;`sourcedDamage` 点位与 `affectedBy` 都不会被移除 | 怪物死亡/离场后地图伤害不消失,`getSeparatedDamage` / `getReducedDamage` 继续返回幽灵伤害,并会污染后续 `refreshIndex` 的重新聚合 | 在 `refreshEnemy` / `refreshEnemyAndClearCache` 建立 sourced 伤害时同步写入 `viewStore.set(viewItem, { damages, enemy })` 与 `damageStore.set(damage, { sourceView, sourceEnemy, index })`,或改为在 `deleteEnemy` 中直接遍历 `sourcedDamage` 的 `affectedBy` 反向清理 | `mapDamage.test.ts` `removes enemy-sourced damage when the enemy is deleted`(#06-01-2) | 中 | diff --git a/packages-user/data-system/src/combat/context.test.ts b/packages-user/data-system/src/combat/context.test.ts new file mode 100644 index 0000000..6470a26 --- /dev/null +++ b/packages-user/data-system/src/combat/context.test.ts @@ -0,0 +1,339 @@ +// 测试怪物上下文:注册与查询、未知查找、删除、resize、范围遍历与各类效果注册表增删 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type IEnemy, type IStateBase } from '@user/data-base'; +import { + type IAuraConverter, + type IEnemyCommonQueryEffect, + type IEnemyFinalEffect, + type IEnemySpecialQueryEffect +} 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; + }; + Map.prototype.getOrInsert ??= function ( + this: Map, + key: K, + defaultValue: V + ): V { + const existing = this.get(key); + if (existing !== undefined) return existing; + this.set(key, defaultValue); + return defaultValue; + }; +}); + +interface TestEnemyAttr { + /** 怪物生命值 */ + hp: number; + /** 怪物攻击力 */ + atk: number; + /** 怪物防御力 */ + def: number; +} + +interface TestHeroAttr { + /** 勇士生命值 */ + hp: number; + /** 勇士攻击力 */ + atk: number; + /** 勇士防御力 */ + def: number; +} + +interface TestModules { + EnemyContext: typeof import('./context').EnemyContext; + Enemy: typeof import('@user/data-base').Enemy; + HeroAttribute: typeof import('@user/data-base').HeroAttribute; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const contextModule = await import('./context'); + const baseModule = await import('@user/data-base'); + modules = { + EnemyContext: contextModule.EnemyContext, + Enemy: baseModule.Enemy, + HeroAttribute: baseModule.HeroAttribute + }; +}); + +interface FakeSpecial { + /** 特殊属性代码 */ + readonly code: number; + + /** + * 深拷贝此特殊属性 + */ + clone(): FakeSpecial; +} + +/** + * 创建一个最小特殊属性对象,只用于驱动注册表的匹配路径 + * @param code 特殊属性代码 + */ +function createSpecial(code: number): FakeSpecial { + return { code, clone: () => createSpecial(code) }; +} + +/** + * 创建一个怪物对象 + * @param id 怪物 id + * @param code 怪物图块数字 + */ +function createEnemy(id: string, code: number): IEnemy { + return new modules.Enemy(id, code, { + hp: 10, + atk: 2, + def: 0 + }); +} + +interface ContextFixture { + /** 被测怪物上下文 */ + context: InstanceType; + /** 可修改勇士属性 */ + hero: InstanceType; +} + +/** + * 创建怪物上下文测试夹具,尺寸默认按 4x3 初始化 + * @param width 上下文宽度 + * @param height 上下文高度 + */ +function createContext(width: number = 4, height: number = 3): ContextFixture { + const state = {} as IStateBase; + const context = new modules.EnemyContext( + state + ); + context.resize(width, height); + const hero = new modules.HeroAttribute({ + hp: 100, + atk: 0, + def: 0 + }); + return { context, hero }; +} + +describe('EnemyContext registry', () => { + // 验证注册怪物后可按定位符、坐标与计算后怪物反查 + it('registers an enemy and resolves it from locator, point and computed', () => { + const fixture = createContext(); + const enemy = createEnemy('e1', 1); + + fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy); + const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 }); + + expect(view).not.toBeNull(); + expect(fixture.context.getEnemyByLoc(1, 0)).toBe(view); + expect(fixture.context.getEnemyLocatorByView(view!)).toEqual({ + x: 1, + y: 0 + }); + expect(fixture.context.getEnemyLocator(enemy)).toEqual({ x: 1, y: 0 }); + expect(fixture.context.getViewByComputed(view!.getComputedEnemy())).toBe( + view + ); + }); + + // 验证未知定位符、未知坐标与未知怪物对象均返回 null + it('returns null for unknown lookups', () => { + const fixture = createContext(); + const other = createEnemy('other', 2); + + expect(fixture.context.getEnemyByLocator({ x: 3, y: 2 })).toBeNull(); + expect(fixture.context.getEnemyByLoc(3, 2)).toBeNull(); + expect(fixture.context.getViewByComputed(other)).toBeNull(); + expect(fixture.context.getEnemyLocator(other)).toBeNull(); + }); + + // 验证同一坐标重复注册会替换原有怪物及其映射 + it('replaces the enemy registered at the same point', () => { + const fixture = createContext(); + const first = createEnemy('first', 1); + const second = createEnemy('second', 2); + + fixture.context.setEnemyAt({ x: 0, y: 0 }, first); + fixture.context.setEnemyAt({ x: 0, y: 0 }, second); + + expect(fixture.context.getEnemyLocator(first)).toBeNull(); + expect(fixture.context.getEnemyLocator(second)).toEqual({ x: 0, y: 0 }); + }); + + // 验证删除怪物会同时移除视图与定位符映射 + it('removes view and locator mappings when deleting an enemy', () => { + const fixture = createContext(); + const enemy = createEnemy('e1', 1); + fixture.context.setEnemyAt({ x: 1, y: 0 }, enemy); + const view = fixture.context.getEnemyByLocator({ x: 1, y: 0 })!; + const computed = view.getComputedEnemy(); + + fixture.context.deleteEnemy({ x: 1, y: 0 }); + + expect(fixture.context.getEnemyByLocator({ x: 1, y: 0 })).toBeNull(); + expect(fixture.context.getEnemyLocatorByView(view)).toBeNull(); + expect(fixture.context.getEnemyLocator(enemy)).toBeNull(); + expect(fixture.context.getViewByComputed(computed)).toBeNull(); + }); + + // 验证 resize 会清空怪物并更新尺寸与索引宽度 + it('resizes by clearing enemies and updating dimensions', () => { + const fixture = createContext(4, 3); + fixture.context.setEnemyAt({ x: 1, y: 0 }, createEnemy('e1', 1)); + + fixture.context.resize(5, 2); + + expect(fixture.context.width).toBe(5); + expect(fixture.context.height).toBe(2); + expect(fixture.context.indexer.locToIndex(3, 1)).toBe(8); + expect(fixture.context.getEnemyByLocator({ x: 1, y: 0 })).toBeNull(); + }); + + // 验证 scanRange 与 iterateEnemy 会遍历全部已注册怪物 + it('scans and iterates all registered enemies', () => { + const fixture = createContext(); + fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('first', 1)); + fixture.context.setEnemyAt({ x: 2, y: 1 }, createEnemy('second', 2)); + const range = { + bindHost: () => {}, + autoDetect: (list: Set) => [...list] + }; + + const scanned = [...fixture.context.scanRange(range as never, 0)]; + const iterated = [...fixture.context.iterateEnemy()]; + + expect(scanned).toHaveLength(2); + expect(iterated).toHaveLength(2); + expect(iterated.map(([locator]) => locator)).toEqual([ + { x: 0, y: 0 }, + { x: 2, y: 1 } + ]); + }); + + // 验证绑定与解绑勇士对象会同步更新查询结果 + it('binds and clears the bound hero', () => { + const fixture = createContext(); + + fixture.context.bindHero(fixture.hero); + expect(fixture.context.getBindedHero()).toBe(fixture.hero); + + fixture.context.bindHero(null); + expect(fixture.context.getBindedHero()).toBeNull(); + }); +}); + +describe('EnemyContext effect registries', () => { + // 验证注册的最终效果会在构建时执行,注销后不再执行 + it('runs registered final effects until they are unregistered', () => { + const fixture = createContext(); + fixture.context.bindHero(fixture.hero); + fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1)); + const applied: number[] = []; + const effect: IEnemyFinalEffect = { + priority: 1, + apply: () => { + applied.push(1); + } + }; + + fixture.context.registerFinalEffect(effect); + fixture.context.buildup(); + expect(applied).toHaveLength(1); + + fixture.context.unregisterFinalEffect(effect); + fixture.context.buildup(); + expect(applied).toHaveLength(1); + }); + + // 验证注册的常规查询效果会按特殊属性代码执行,注销后不再执行 + it('runs common query effects for matching specials until unregistered', () => { + const fixture = createContext(); + fixture.context.bindHero(fixture.hero); + const enemy = createEnemy('e1', 1); + enemy.addSpecial(createSpecial(20) as never); + fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy); + const queried: number[] = []; + const effect: IEnemyCommonQueryEffect = { + priority: 1, + apply: () => { + queried.push(1); + } + }; + + fixture.context.registerCommonQueryEffect(20, effect); + fixture.context.buildup(); + expect(queried).toHaveLength(1); + + fixture.context.unregisterCommonQueryEffect(20, effect); + fixture.context.buildup(); + expect(queried).toHaveLength(1); + }); + + // 验证注册的特殊查询效果会在构建时构造修饰器,注销后不再构造 + it('builds special query modifiers until unregistered', () => { + const fixture = createContext(); + fixture.context.bindHero(fixture.hero); + fixture.context.setEnemyAt({ x: 0, y: 0 }, createEnemy('e1', 1)); + const forCalls: number[] = []; + const modifier = { + add: () => [], + delete: () => [], + modify: () => false, + shouldQuery: () => false + }; + const effect: IEnemySpecialQueryEffect = { + priority: 5, + for: () => { + forCalls.push(1); + return modifier; + } + }; + + fixture.context.registerSpecialQueryEffect(effect); + fixture.context.buildup(); + expect(forCalls).toHaveLength(1); + + fixture.context.unregisterSpecialQueryEffect(effect); + fixture.context.buildup(); + expect(forCalls).toHaveLength(1); + }); + + // 验证注册的光环转换器会参与特殊属性转换,注销后不再参与 + it('consults aura converters until they are unregistered', () => { + const fixture = createContext(); + fixture.context.bindHero(fixture.hero); + const enemy = createEnemy('e1', 1); + enemy.addSpecial(createSpecial(20) as never); + fixture.context.setEnemyAt({ x: 0, y: 0 }, enemy); + const consulted: number[] = []; + const converter: IAuraConverter = { + shouldConvert: () => { + consulted.push(1); + return false; + }, + convert: () => null as never + }; + + fixture.context.registerAuraConverter(converter); + fixture.context.buildup(); + expect(consulted).toHaveLength(1); + + fixture.context.unregisterAuraConverter(converter); + fixture.context.buildup(); + expect(consulted).toHaveLength(1); + }); +}); diff --git a/packages-user/data-system/src/combat/mapDamage.test.ts b/packages-user/data-system/src/combat/mapDamage.test.ts new file mode 100644 index 0000000..a78654a --- /dev/null +++ b/packages-user/data-system/src/combat/mapDamage.test.ts @@ -0,0 +1,440 @@ +// 测试地图伤害:无来源伤害增删、有来源转换与合并、告警码 102/103/104、分离伤害合并与 deleteEnemy +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { + type IRange, + type ITileLocator, + type IRangeHost +} from '@motajs/common'; +import { type IEnemy, type IStateBase } from '@user/data-base'; +import { + type IEnemyContext, + type IEnemyView, + type IMapDamageConverter, + type IMapDamageInfo, + type IMapDamageReducer, + type IMapDamageView +} 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; + }; + Map.prototype.getOrInsert ??= function ( + this: Map, + key: K, + defaultValue: V + ): V { + const existing = this.get(key); + if (existing !== undefined) return existing; + this.set(key, defaultValue); + return defaultValue; + }; +}); + +interface TestEnemyAttr { + /** 怪物生命值 */ + hp: number; + /** 怪物攻击力 */ + atk: number; + /** 怪物防御力 */ + def: number; +} + +interface TestHeroAttr { + /** 勇士生命值 */ + hp: number; + /** 勇士攻击力 */ + atk: number; + /** 勇士防御力 */ + def: number; +} + +interface TestModules { + MapDamage: typeof import('./mapDamage').MapDamage; + MapLocIndexer: typeof import('@user/data-common').MapLocIndexer; + HeroAttribute: typeof import('@user/data-base').HeroAttribute; + Enemy: typeof import('@user/data-base').Enemy; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const mapDamageModule = await import('./mapDamage'); + const commonModule = await import('@user/data-common'); + const motaModule = await import('@motajs/common'); + const baseModule = await import('@user/data-base'); + modules = { + MapDamage: mapDamageModule.MapDamage, + MapLocIndexer: commonModule.MapLocIndexer, + HeroAttribute: baseModule.HeroAttribute, + Enemy: baseModule.Enemy, + logger: motaModule.logger + }; +}); + +/** + * 构造一条地图伤害信息 + * @param damage 伤害值 + */ +function createInfo(damage: number): IMapDamageInfo { + return { + damage, + type: 0, + extra: { catch: new Set(), repulse: new Set() } + }; +} + +interface IFakeRange { + /** + * 绑定宿主对象 + * @param host 宿主对象 + */ + bindHost(host: IRangeHost): void; + + /** + * 迭代范围内的坐标索引 + * @param param 范围参数 + */ + iterateLoc(param: number): Iterable; +} + +/** + * 只覆盖固定坐标索引集合的测试范围,用于驱动有来源伤害转换 + */ +class FakeRange implements IFakeRange { + /** 该范围覆盖的坐标索引 */ + readonly indexes: number[]; + + constructor(indexes: number[]) { + this.indexes = indexes; + } + + bindHost(_host: IRangeHost): void {} + + *iterateLoc(): Iterable { + yield* this.indexes; + } +} + +/** + * 固定返回一条地图伤害的测试视图 + */ +class FakeView implements IMapDamageView { + /** 该视图产生的伤害值 */ + readonly value: number; + /** 该视图影响的范围 */ + readonly range: FakeRange; + + constructor(value: number, indexes: number[]) { + this.value = value; + this.range = new FakeRange(indexes); + } + + getRange(): IRange { + return this.range as never; + } + + getRangeParam(): number { + return 0; + } + + getDamageAt(_locator: ITileLocator): Readonly | null { + return createInfo(this.value); + } + + getDamageWithoutCheck( + _locator: ITileLocator + ): Readonly | null { + return createInfo(this.value); + } +} + +/** + * 固定输出伤害视图列表的测试转换器 + */ +class FakeConverter + implements IMapDamageConverter +{ + /** 转换输出的伤害视图 */ + readonly views: IMapDamageView[]; + + constructor(views: IMapDamageView[]) { + this.views = views; + } + + convert(): IMapDamageView[] { + return this.views; + } +} + +/** + * 求和式测试合并器,并记录调用次数 + */ +class FakeReducer implements IMapDamageReducer { + /** reduce 调用次数 */ + calls: number = 0; + + reduce( + info: Iterable> + ): Readonly { + this.calls++; + let damage = 0; + for (const item of info) { + damage += item.damage; + } + return createInfo(damage); + } +} + +interface MapDamageFixture { + /** 被测地图伤害对象 */ + damage: InstanceType; + /** 怪物上下文假对象 */ + context: IEnemyContext; + /** 测试转换器 */ + converter: FakeConverter; + /** 测试合并器 */ + reducer: FakeReducer; + /** 已注册的怪物视图 */ + view: IEnemyView; + /** 转换出的伤害视图 */ + damageView: FakeView; + /** 已注册怪物的位置 */ + locator: ITileLocator; + /** 可修改原始怪物 */ + origin: IEnemy; +} + +/** + * 创建地图伤害测试夹具,怪物上下文只实现被测路径所需的最小接口 + */ +function createFixture(): MapDamageFixture { + const state = {} as IStateBase; + const indexer = new modules.MapLocIndexer(); + indexer.setWidth(4); + const hero = new modules.HeroAttribute({ + hp: 100, + atk: 0, + def: 0 + }); + const origin = new modules.Enemy('enemy-1', 1, { + hp: 10, + atk: 2, + def: 0 + }); + const locator: ITileLocator = { x: 1, y: 0 }; + const enemies = new Map, ITileLocator>(); + const view: IEnemyView = { + context: {} as never, + reset: () => {}, + getBaseEnemy: () => origin, + getComputedEnemy: () => origin, + getModifiableEnemy: () => origin, + markDirty: () => {} + }; + enemies.set(view, locator); + const damageView = new FakeView( + 7, + [indexer.locToIndex(locator.x, locator.y)] + ); + const context = { + state, + indexer, + width: 4, + height: 3, + getBindedHero: () => hero, + getEnemyLocatorByView: (target: IEnemyView) => + enemies.get(target) ?? null, + getEnemyLocator: (target: IEnemy) => + target === origin ? locator : null, + getViewByComputed: () => view, + *iterateEnemy(): Iterable< + [ITileLocator, IEnemyView] + > { + for (const [target, targetLocator] of enemies) { + yield [targetLocator, target]; + } + } + } as never; + const damage = new modules.MapDamage( + context + ); + return { + damage, + context, + converter: new FakeConverter([damageView]), + reducer: new FakeReducer(), + view, + damageView, + locator, + origin + }; +} + +/** + * 创建一个未注册到夹具中的怪物视图,用于验证未知定位符路径 + */ +function createUnknownView(fixture: MapDamageFixture): IEnemyView { + return { + context: {} as never, + reset: () => {}, + getBaseEnemy: () => fixture.origin, + getComputedEnemy: () => fixture.origin, + getModifiableEnemy: () => fixture.origin, + markDirty: () => {} + }; +} + +describe('MapDamage sourceless damage', () => { + // 验证添加与删除无来源地图伤害会同步更新分离伤害列表 + it('adds and deletes sourceless damage at a point', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + const info = createInfo(5); + + fixture.damage.addMapDamage({ x: 0, y: 0 }, info); + expect( + [...fixture.damage.getSeparatedDamage({ x: 0, y: 0 })] + ).toContain(info); + + fixture.damage.deleteMapDamage({ x: 0, y: 0 }, info); + expect( + [...fixture.damage.getSeparatedDamage({ x: 0, y: 0 })] + ).toHaveLength(0); + }); + + // 验证删除不存在的无来源伤害是安全操作 + it('ignores deleting sourceless damage that was never added', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + + fixture.damage.deleteMapDamage({ x: 3, y: 2 }, createInfo(5)); + + expect( + [...fixture.damage.getSeparatedDamage({ x: 3, y: 2 })] + ).toHaveLength(0); + }); +}); + +describe('MapDamage sourced conversion and reduction', () => { + // 验证转换出的有来源伤害可被分离查询与合并查询获取 + it('converts sourced damage and reduces it through the reducer', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + + const separated = [ + ...fixture.damage.getSeparatedDamage(fixture.locator) + ]; + const reduced = fixture.damage.getReducedDamage(fixture.locator); + + expect(separated).toHaveLength(1); + expect(separated[0].damage).toBe(7); + expect(reduced?.damage).toBe(7); + expect(fixture.reducer.calls).toBe(1); + }); + + // 验证同一坐标上无来源与有来源伤害会被合并查询同时返回 + it('unions sourceless and sourced damage at the same point', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + fixture.damage.addMapDamage(fixture.locator, createInfo(3)); + + expect( + [...fixture.damage.getSeparatedDamage(fixture.locator)] + ).toHaveLength(2); + + const reduced = fixture.damage.getReducedDamage(fixture.locator); + expect(reduced?.damage).toBe(10); + }); + + // 验证合并结果命中缓存,markDirty 后按新数据重新合并 + it('caches the reduced damage until the point is marked dirty', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.addMapDamage({ x: 0, y: 0 }, createInfo(3)); + + const first = fixture.damage.getReducedDamage({ x: 0, y: 0 }); + const second = fixture.damage.getReducedDamage({ x: 0, y: 0 }); + + expect(second).toBe(first); + expect(fixture.reducer.calls).toBe(1); + + fixture.damage.markDirty({ x: 0, y: 0 }); + const third = fixture.damage.getReducedDamage({ x: 0, y: 0 }); + + expect(third).not.toBe(first); + expect(third?.damage).toBe(3); + expect(fixture.reducer.calls).toBe(2); + }); + + // 验证没有任何分离伤害时合并查询返回 null + it('returns null when a point has no separated damage', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + + expect(fixture.damage.getReducedDamage({ x: 3, y: 2 })).toBeNull(); + }); + + // 验证未设置转换器时全量刷新告警 102 并直接返回 + it('warns 102 when the converter is missing during a full refresh', () => { + const fixture = createFixture(); + + const result = modules.logger.catch(() => fixture.damage.refreshAll()); + + expect(result.info.map(v => v.code)).toContain(102); + }); + + // 验证未设置合并器时合并查询告警 103 并返回 null + it('warns 103 when the reducer is missing', () => { + const fixture = createFixture(); + + const result = modules.logger.catch(() => + fixture.damage.getReducedDamage({ x: 0, y: 0 }) + ); + + expect(result.ret).toBeNull(); + expect(result.info.map(v => v.code)).toContain(103); + }); + + // 验证既无伤害来源又无定位符时标记怪物脏会告警 104 + it('warns 104 when marking an unregistered enemy dirty', () => { + const fixture = createFixture(); + + const result = modules.logger.catch(() => + fixture.damage.markEnemyDirty(createUnknownView(fixture)) + ); + + expect(result.info.map(v => v.code)).toContain(104); + }); + + // 疑似 bug:deleteEnemy 应移除该怪物带来的有来源地图伤害,详见 06-TEST-FINDINGS.md #06-01-2,修复后取消 skip + it.skip('removes enemy-sourced damage when the enemy is deleted', () => { + const fixture = createFixture(); + fixture.damage.useReducer(fixture.reducer); + fixture.damage.useConverter(fixture.converter); + expect( + [...fixture.damage.getSeparatedDamage(fixture.locator)] + ).toHaveLength(1); + + fixture.damage.deleteEnemy(fixture.view); + + expect( + [...fixture.damage.getSeparatedDamage(fixture.locator)] + ).toHaveLength(0); + }); +});