diff --git a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md new file mode 100644 index 0000000..215636d --- /dev/null +++ b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md @@ -0,0 +1,26 @@ +# Phase 6: 单元测试 - 问题记录 + +> 本文件为**共享追加式**问题记录。各计划只追加自己的 `#06-XX` 小节,绝不重写其它小节。 +> 单测暴露疑似 bug 时,测试按**正确预期**编写并标记 `it.skip` / `it.todo`,同时在此登记 +> 对应条目;`pnpm test:ci` 必须保持全绿(06-CONTEXT D-05 / D-06)。 + +每条记录包含以下字段: + +| 字段 | 含义 | +| --- | --- | +| 模块/接口 | 出问题的模块与具体接口 | +| 现象 | 观察到的行为 | +| 最小复现 | 触发条件与输入 | +| 疑似原因 | 当前对根因的判断 | +| 影响面 | 受影响的调用方/功能 | +| 建议修复方向 | 建议的修法(本阶段不修改核心代码) | +| 关联 skip 用例 | 指向对应 `it.skip` / `it.todo` 的用例名 | +| 严重度 | 高 / 中 / 低 | + +--- + +## #06-01 战斗系统(data-system/src/combat) + +| 模块/接口 | 现象 | 最小复现 | 疑似原因 | 影响面 | 建议修复方向 | 关联 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) | 中 | diff --git a/.planning/phases/06-unit-tests/COVERAGE.md b/.planning/phases/06-unit-tests/COVERAGE.md new file mode 100644 index 0000000..a7d3dd0 --- /dev/null +++ b/.planning/phases/06-unit-tests/COVERAGE.md @@ -0,0 +1,5 @@ +# Phase 6: 单元测试 - API Coverage 声明 + +本阶段不涉及任何外部 API 集成:只新增仓库内的 Vitest 行为测试,被测对象是仅在 Node 环境运行的数据端(`@user/data-common` / `@user/data-base` / `@user/data-system` / `@user/data-state`)。本阶段不新增也不调用任何外部 SDK、HTTP 客户端或 CLI 客户端,因此不存在需要额外声明的外部接口覆盖。 + +计划 06-01 的 api-coverage 贡献为:`packages-user/data-system/src/combat` 的 Layer-2 战斗契约(`DamageContext` / `DamageSystem` / `MapDamage` / `EnemyContext` / `CombatFlow`)行为覆盖,全部经由项目自身的模块路径与 `@user/data-base` / `@user/data-common` / `@motajs/common` barrel 导入,不 mock 数据层接口本身。 diff --git a/packages-user/data-system/src/combat/damage.test.ts b/packages-user/data-system/src/combat/damage.test.ts new file mode 100644 index 0000000..99e532d --- /dev/null +++ b/packages-user/data-system/src/combat/damage.test.ts @@ -0,0 +1,495 @@ +// 测试战斗伤害上下文与伤害系统:结果展开、handler 身份、告警码 106/107、缓存与 with、临界生成 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ITileLocator } from '@motajs/common'; +import { type IEnemy, type IStateBase } from '@user/data-base'; +import { + type CriticalableHeroStatus, + type IDamageCalculator, + type IEnemyContext, + type IEnemyDamageInfoBase, + type IEnemyView, + type IReadonlyEnemy, + type IReadonlyEnemyHandler +} 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 TestEnemyAttr { + /** 怪物生命值 */ + hp: number; + /** 怪物攻击力 */ + atk: number; + /** 怪物防御力 */ + def: number; +} + +interface TestHeroAttr { + /** 勇士生命值 */ + hp: number; + /** 勇士攻击力 */ + atk: number; + /** 勇士防御力 */ + def: number; +} + +interface TestModules { + DamageContext: typeof import('./damage').DamageContext; + DamageSystem: typeof import('./damage').DamageSystem; + HeroAttribute: typeof import('@user/data-base').HeroAttribute; + Enemy: typeof import('@user/data-base').Enemy; + MapLocIndexer: typeof import('@user/data-common').MapLocIndexer; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const damageModule = await import('./damage'); + const baseModule = await import('@user/data-base'); + const commonModule = await import('@user/data-common'); + const motaModule = await import('@motajs/common'); + modules = { + DamageContext: damageModule.DamageContext, + DamageSystem: damageModule.DamageSystem, + HeroAttribute: baseModule.HeroAttribute, + Enemy: baseModule.Enemy, + MapLocIndexer: commonModule.MapLocIndexer, + logger: motaModule.logger + }; +}); + +/** + * 固定伤害计算器:每点攻击减免固定伤害,用于验证结果展开与临界生成 + */ +class FakeCalculator + implements IDamageCalculator +{ + /** 每点攻击减免的伤害值 */ + readonly damagePerAtk: number; + /** calculate 调用次数 */ + calls: number = 0; + + constructor(damagePerAtk: number = 10) { + this.damagePerAtk = damagePerAtk; + } + + calculate( + handler: IReadonlyEnemyHandler + ): IEnemyDamageInfoBase { + this.calls++; + const atk = handler.hero.getBaseAttribute('atk'); + return { + damage: Math.max(0, 100 - atk * this.damagePerAtk), + turn: 2 + }; + } + + getCriticalLimit( + _handler: IReadonlyEnemyHandler, + _attribute: CriticalableHeroStatus + ): number { + return 10; + } +} + +interface DamageFixture { + /** 怪物上下文假对象 */ + context: IEnemyContext; + /** 固定伤害计算器 */ + calculator: FakeCalculator; + /** 可修改勇士属性 */ + hero: InstanceType; + /** 怪物视图假对象 */ + view: IEnemyView; + /** 计算后怪物 */ + computed: IReadonlyEnemy; + /** 可修改原始怪物 */ + origin: IEnemy; + /** 怪物位置 */ + locator: ITileLocator; +} + +/** + * 创建伤害测试夹具,怪物上下文只实现被测路径所需的最小接口 + */ +function createFixture(): DamageFixture { + 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: 30, + atk: 8, + def: 2 + }); + const computed = origin.clone(); + const locator: ITileLocator = { x: 2, y: 3 }; + const locators = new Map, ITileLocator>(); + const computedViews = new Map< + IReadonlyEnemy, + IEnemyView + >(); + const view: IEnemyView = { + context: {} as never, + reset: () => {}, + getBaseEnemy: () => origin, + getComputedEnemy: () => computed, + getModifiableEnemy: () => origin, + markDirty: () => {} + }; + locators.set(view, locator); + computedViews.set(computed, view); + const context = { + state, + indexer, + getBindedHero: () => hero, + getEnemyLocatorByView: (target: IEnemyView) => + locators.get(target) ?? null, + getViewByComputed: (target: IReadonlyEnemy) => + computedViews.get(target) ?? null + } as never; + return { + context, + calculator: new FakeCalculator(), + hero, + view, + computed, + origin, + locator + }; +} + +/** + * 创建一个未注册到夹具中的怪物视图,用于验证未知定位符路径 + */ +function createUnknownView(fixture: DamageFixture): IEnemyView { + return { + context: {} as never, + reset: () => {}, + getBaseEnemy: () => fixture.origin, + getComputedEnemy: () => fixture.computed, + getModifiableEnemy: () => fixture.origin, + markDirty: () => {} + }; +} + +/** + * 创建一个绑定给定夹具的只读信息对象 + */ +function createHandler( + fixture: DamageFixture +): IReadonlyEnemyHandler { + return { + enemy: fixture.computed, + context: fixture.context, + locator: fixture.locator, + hero: fixture.hero, + state: fixture.context.state + }; +} + +describe('DamageContext behaviour', () => { + // 验证已绑定计算器与勇士时按视图返回计算器结果,并携带正确身份的 handler + it('returns calculator output with an identity handler for a view', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const info = context.getDamageInfo(fixture.view); + + expect(info?.damage).toBe(100); + expect(info?.turn).toBe(2); + expect(info?.handler.enemy).toBe(fixture.computed); + expect(info?.handler.hero).toBe(fixture.hero); + expect(info?.handler.locator).toBe(fixture.locator); + expect(info?.handler.state).toBe(fixture.context.state); + }); + + // 验证按计算后怪物反查视图并返回相同的伤害信息 + it('resolves a computed enemy back to its view', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const info = context.getDamageInfoByComputed(fixture.computed); + + expect(info?.damage).toBe(100); + expect(info?.handler.enemy).toBe(fixture.computed); + }); + + // 验证直接传入 handler 时原样返回该 handler 与计算器结果 + it('returns the raw calculator result for a supplied handler', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + const handler = createHandler(fixture); + + const info = context.getDamageInfoByHandler(handler); + + expect(info?.handler).toBe(handler); + expect(info?.damage).toBe(100); + expect(info?.turn).toBe(2); + }); + + // 验证未绑定勇士时告警 107 并返回 null + it('warns 107 when hero status is missing', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + null + ); + + const result = modules.logger.catch(() => + context.getDamageInfo(fixture.view) + ); + + expect(result.ret).toBeNull(); + expect(result.info.map(v => v.code)).toContain(107); + }); + + // 验证未绑定计算器时告警 106 并返回 null(视图、计算后怪物与 handler 三条路径) + it('warns 106 when the calculator is missing', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + null, + fixture.hero + ); + + const byView = modules.logger.catch(() => + context.getDamageInfo(fixture.view) + ); + const byComputed = modules.logger.catch(() => + context.getDamageInfoByComputed(fixture.computed) + ); + const byHandler = modules.logger.catch(() => + context.getDamageInfoByHandler(createHandler(fixture)) + ); + + expect(byView.ret).toBeNull(); + expect(byView.info.map(v => v.code)).toContain(106); + expect(byComputed.ret).toBeNull(); + expect(byComputed.info.map(v => v.code)).toContain(106); + expect(byHandler.ret).toBeNull(); + expect(byHandler.info.map(v => v.code)).toContain(106); + }); + + // 验证视图查不到定位符时按 null 返回且不产生告警 + it('returns null without warning for a view without a locator', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const result = modules.logger.catch(() => + context.getDamageInfo(createUnknownView(fixture)) + ); + + expect(result.ret).toBeNull(); + expect(result.info).toHaveLength(0); + }); +}); + +describe('DamageSystem caching', () => { + // 验证连续两次取伤害命中同一缓存对象,markDirty 之后重新计算 + it('caches the damage info until the enemy is marked dirty', () => { + const fixture = createFixture(); + const system = new modules.DamageSystem(fixture.context); + system.useCalculator(fixture.calculator); + system.bindHeroStatus(fixture.hero); + + const first = system.getDamageInfo(fixture.view); + const second = system.getDamageInfo(fixture.view); + + expect(second).toBe(first); + expect(fixture.calculator.calls).toBe(1); + + system.markDirty(fixture.view); + const third = system.getDamageInfo(fixture.view); + + expect(third).not.toBe(first); + expect(third?.damage).toBe(first?.damage); + expect(fixture.calculator.calls).toBe(2); + }); + + // 验证 markAllDirty、useCalculator、bindHeroStatus 与 deleteEnemy 都会让缓存失效 + it('invalidates the cache on system mutations', () => { + const fixture = createFixture(); + const system = new modules.DamageSystem(fixture.context); + system.useCalculator(fixture.calculator); + system.bindHeroStatus(fixture.hero); + + const first = system.getDamageInfo(fixture.view); + + system.markAllDirty(); + const second = system.getDamageInfo(fixture.view); + expect(second).not.toBe(first); + + system.useCalculator(fixture.calculator); + const third = system.getDamageInfo(fixture.view); + expect(third).not.toBe(second); + + system.bindHeroStatus(fixture.hero); + const fourth = system.getDamageInfo(fixture.view); + expect(fourth).not.toBe(third); + + system.deleteEnemy(fixture.view); + const fifth = system.getDamageInfo(fixture.view); + expect(fifth).not.toBe(fourth); + }); + + // 验证按计算后怪物查询同样命中缓存 + it('caches the damage info resolved from a computed enemy', () => { + const fixture = createFixture(); + const system = new modules.DamageSystem(fixture.context); + system.useCalculator(fixture.calculator); + system.bindHeroStatus(fixture.hero); + + const first = system.getDamageInfoByComputed(fixture.computed); + const second = system.getDamageInfoByComputed(fixture.computed); + + expect(second).toBe(first); + }); + + // 验证 with(hero) 返回共享计算器、使用传入勇士的独立伤害上下文 + it('builds an isolated damage context sharing the calculator', () => { + const fixture = createFixture(); + const system = new modules.DamageSystem(fixture.context); + system.useCalculator(fixture.calculator); + system.bindHeroStatus(fixture.hero); + const otherHero = new modules.HeroAttribute({ + hp: 50, + atk: 4, + def: 1 + }); + + const scoped = system.with(otherHero); + const info = scoped.getDamageInfo(fixture.view); + + expect(system.getCalculator()).toBe(fixture.calculator); + expect(scoped).not.toBe(system); + expect(info?.handler.hero).toBe(otherHero); + expect(info?.damage).toBe(60); + }); +}); + +describe('DamageContext critical generation', () => { + // 验证从当前属性出发产出下一临界点及其增量与基准伤害 + it('yields the next critical point with its deltas', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const first = context.calculateCritical(fixture.view, 'atk').next() + .value!; + + expect(first.baseValue).toBe(0); + expect(first.nextValue).toBe(1); + expect(first.nextDiff).toBe(1); + expect(first.baseInfo.damage).toBe(100); + }); + + // 验证属性已达到临界上界时不产出任何临界点 + it('yields nothing when the attribute already reaches the limit', () => { + const fixture = createFixture(); + fixture.hero.set('atk', 10); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const results = [...context.calculateCritical(fixture.view, 'atk')]; + + expect(results).toEqual([]); + }); + + // 验证缺少勇士、缺少计算器或缺少定位符时安全返回且告警对应错误码 + it('returns safely when hero, calculator or locator is missing', () => { + const fixture = createFixture(); + const noHero = new modules.DamageContext( + fixture.context, + fixture.calculator, + null + ); + const noCalculator = new modules.DamageContext( + fixture.context, + null, + fixture.hero + ); + const withLocator = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const heroResult = modules.logger.catch(() => [ + ...noHero.calculateCritical(fixture.view, 'atk') + ]); + const calculatorResult = modules.logger.catch(() => [ + ...noCalculator.calculateCritical(fixture.view, 'atk') + ]); + const locatorResult = modules.logger.catch(() => [ + ...withLocator.calculateCritical(createUnknownView(fixture), 'atk') + ]); + + expect(heroResult.ret).toEqual([]); + expect(heroResult.info.map(v => v.code)).toContain(107); + expect(calculatorResult.ret).toEqual([]); + expect(calculatorResult.info.map(v => v.code)).toContain(106); + expect(locatorResult.ret).toEqual([]); + expect(locatorResult.info).toHaveLength(0); + }); + + // 疑似 bug:产出的 info 应与 nextValue 对应,详见 06-TEST-FINDINGS.md #06-01-1,修复后取消 skip + it.skip('reports the damage info matching the yielded critical value', () => { + const fixture = createFixture(); + const context = new modules.DamageContext( + fixture.context, + fixture.calculator, + fixture.hero + ); + + const first = context.calculateCritical(fixture.view, 'atk').next() + .value!; + + expect(first.nextValue).toBe(1); + expect(first.info.damage).toBe(90); + expect(first.damageDiff).toBe(-10); + }); +});