From 6c8f85a2be68d9696844ee863a8bb6787b6fde45 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 14 Sep 2026 08:51:13 +0800 Subject: [PATCH] test(06-01): cover CombatFlow binding, script ordering and guards - add combat.test.ts for same-state binding, foreign-state warn 138, script priority sort and duplicate warn 140, guard warns 139/141, awaited script/hook ordering with real timers, and battleComputed delegation - extend the 06-TEST-FINDINGS.md sink with the #06-01-3 before-return semantics row --- .../phases/06-unit-tests/06-TEST-FINDINGS.md | 1 + .../data-system/src/combat/combat.test.ts | 439 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 packages-user/data-system/src/combat/combat.test.ts diff --git a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md index b3d3840..b5cd2c9 100644 --- a/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md +++ b/.planning/phases/06-unit-tests/06-TEST-FINDINGS.md @@ -25,3 +25,4 @@ | --- | --- | --- | --- | --- | --- | --- | --- | | `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) | 中 | +| `combat.ts` `CombatFlow.combatFlow` / `ICombatScript.before` | 实现与接口文档语义相反:接口声明返回 `false` 放弃战斗,实现却在返回**真值**时短路 | 加载一个 `onBeforeCombat` 钩子,脚本 `before` 返回 `false`;`battle()` 仍会执行该钩子(正确实现应直接返回并放弃) | `const skip = await script.before(...); if (skip) return damage;` 以真值短路,与文档「返回 false 会立刻停止并放弃此次战斗」相反;文档或实现其一有误 | 所有战斗脚本的短路约定:按接口文档编写的脚本会得到相反效果,可能漏战或误战 | 先与接口设计者确认语义,再二选一统一:以 `true` 短路为准则修正接口注释,以文档为准则改为 `if (!skip) return damage;` | `combat.test.ts` `abandons the battle when the before script returns false`(#06-01-3) | 中 | diff --git a/packages-user/data-system/src/combat/combat.test.ts b/packages-user/data-system/src/combat/combat.test.ts new file mode 100644 index 0000000..544c2c3 --- /dev/null +++ b/packages-user/data-system/src/combat/combat.test.ts @@ -0,0 +1,439 @@ +// 测试战斗流程:同源绑定与外来绑定、脚本优先级与去重、缺参告警 139/141、真实计时器下的 await 顺序 +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { type ITileLocator } from '@motajs/common'; +import { + type IEnemy, + type IReadonlyEnemy, + type IStateBase +} from '@user/data-base'; +import { + type ICombatScript, + type IDamageContext, + type IEnemyContext, + type IEnemyDamageInfo, + type IEnemyView +} 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 { + CombatFlow: typeof import('./combat').CombatFlow; + Enemy: typeof import('@user/data-base').Enemy; + HeroAttribute: typeof import('@user/data-base').HeroAttribute; + logger: typeof import('@motajs/common').logger; +} + +let modules: TestModules; + +beforeAll(async () => { + vi.stubGlobal('main', { replayChecking: true }); + vi.stubGlobal('location', { origin: 'http://localhost' }); + const combatModule = await import('./combat'); + const baseModule = await import('@user/data-base'); + const motaModule = await import('@motajs/common'); + modules = { + CombatFlow: combatModule.CombatFlow, + Enemy: baseModule.Enemy, + HeroAttribute: baseModule.HeroAttribute, + logger: motaModule.logger + }; +}); + +interface Deferred { + /** 由外部释放的等待 promise */ + readonly promise: Promise; + + /** + * 释放等待 + */ + resolve(): void; +} + +/** + * 创建一个可手动释放的异步闸门,用于验证真实计时器下的 await 顺序 + */ +function createDeferred(): Deferred { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +/** + * 记录调用顺序的测试战斗脚本 + */ +class FakeScript implements ICombatScript { + readonly priority: number; + /** 用于区分同一次运行内的多个脚本 */ + readonly label: string; + /** 共享的调用顺序记录 */ + readonly calls: string[]; + /** before 的返回值,真值表示短路 */ + beforeResult: boolean; + /** before 需要等待的异步闸门 */ + gate: Deferred | null = null; + + constructor( + priority: number, + label: string, + calls: string[], + beforeResult: boolean = false + ) { + this.priority = priority; + this.label = label; + this.calls = calls; + this.beforeResult = beforeResult; + } + + async before(): Promise { + this.calls.push(`${this.label}.before`); + if (this.gate) await this.gate.promise; + return this.beforeResult; + } + + async after(): Promise { + this.calls.push(`${this.label}.after`); + } +} + +interface CombatFixture { + /** 被测战斗流程对象 */ + flow: InstanceType; + /** 数据层状态假对象 */ + state: IStateBase; + /** 怪物上下文假对象 */ + context: IEnemyContext; + /** 伤害上下文假对象 */ + damage: IDamageContext; + /** 可修改勇士属性 */ + hero: InstanceType; + /** 怪物视图假对象 */ + view: IEnemyView; + /** 计算后怪物 */ + computed: IReadonlyEnemy; + /** 可修改原始怪物 */ + origin: IEnemy; + /** 怪物位置 */ + locator: ITileLocator; + /** 共享调用顺序记录 */ + calls: string[]; + /** 伤害上下文固定返回的信息对象 */ + info: IEnemyDamageInfo; +} + +/** + * 创建战斗流程测试夹具,怪物/伤害上下文只实现被测路径所需的最小接口 + */ +function createFixture(): CombatFixture { + const state = {} as IStateBase; + const calls: string[] = []; + const origin = new modules.Enemy('enemy-1', 1, { + hp: 10, + atk: 2, + def: 0 + }); + const computed = origin.clone(); + const locator: ITileLocator = { x: 1, y: 1 }; + const view: IEnemyView = { + context: {} as never, + reset: () => {}, + getBaseEnemy: () => origin, + getComputedEnemy: () => computed, + getModifiableEnemy: () => origin, + markDirty: () => {} + }; + const hero = new modules.HeroAttribute({ + hp: 100, + atk: 0, + def: 0 + }); + const context = { + state, + width: 2, + height: 2, + getEnemyLocatorByView: () => locator, + getEnemyLocator: () => locator, + getViewByComputed: () => view, + getEnemyByLocator: () => view, + getEnemyByLoc: () => view + } as never; + const info = { handler: {} as never, damage: 5, turn: 2 } as never; + const damage = { + state, + getDamageInfoByHandler: () => info + } as never; + const flow = new modules.CombatFlow(state); + return { + flow, + state, + context, + damage, + hero, + view, + computed, + origin, + locator, + calls, + info + }; +} + +/** + * 把夹具的怪物上下文、伤害上下文与勇士全部绑定到战斗流程上 + */ +function bindAll(fixture: CombatFixture): void { + fixture.flow.bindContext(fixture.context); + fixture.flow.bindDamage(fixture.damage); + fixture.flow.bindHero(fixture.hero); +} + +describe('CombatFlow binding', () => { + // 验证同 state 的怪物与伤害上下文可绑定,传入 null 会清空绑定 + it('binds same-state collaborators and clears them with null', () => { + const fixture = createFixture(); + + fixture.flow.bindContext(fixture.context); + fixture.flow.bindDamage(fixture.damage); + expect(fixture.flow.context).toBe(fixture.context); + expect(fixture.flow.damage).toBe(fixture.damage); + + fixture.flow.bindContext(null); + fixture.flow.bindDamage(null); + expect(fixture.flow.context).toBeNull(); + expect(fixture.flow.damage).toBeNull(); + }); + + // 验证绑定外来 state 的上下文或伤害对象时告警 138 且保持未绑定 + it('warns 138 for collaborators bound to a foreign state', () => { + const fixture = createFixture(); + const foreignState = {} as IStateBase; + const foreignContext = { state: foreignState } as never; + const foreignDamage = { state: foreignState } as never; + + const contextResult = modules.logger.catch(() => + fixture.flow.bindContext(foreignContext) + ); + const damageResult = modules.logger.catch(() => + fixture.flow.bindDamage(foreignDamage) + ); + + expect(fixture.flow.context).toBeNull(); + expect(fixture.flow.damage).toBeNull(); + expect(contextResult.info.map(v => v.code)).toContain(138); + expect(damageResult.info.map(v => v.code)).toContain(138); + }); + + // 验证绑定与清空勇士对象会同步更新 hero 成员 + it('binds and clears the hero reference', () => { + const fixture = createFixture(); + + fixture.flow.bindHero(fixture.hero); + expect(fixture.flow.hero).toBe(fixture.hero); + + fixture.flow.bindHero(null); + expect(fixture.flow.hero).toBeNull(); + }); +}); + +describe('CombatFlow scripts and guards', () => { + // 验证脚本按优先级降序执行,重复优先级告警 140 且不加入列表 + it('sorts scripts by descending priority and rejects duplicates', async () => { + const fixture = createFixture(); + bindAll(fixture); + const low = new FakeScript(1, 'low', fixture.calls); + const high = new FakeScript(2, 'high', fixture.calls); + const duplicate = new FakeScript(1, 'duplicate', fixture.calls); + fixture.flow.addCombatScript(low); + fixture.flow.addCombatScript(high); + + const result = modules.logger.catch(() => + fixture.flow.addCombatScript(duplicate) + ); + + expect(result.info.map(v => v.code)).toContain(140); + + await fixture.flow.battle(fixture.view); + expect(fixture.calls).toEqual([ + 'high.before', + 'low.before', + 'high.after', + 'low.after' + ]); + }); + + // 验证缺少怪物上下文、勇士或伤害上下文时告警 139 并返回 null + it('warns 139 when a required collaborator is missing', async () => { + const noContext = createFixture(); + const contextResult = modules.logger.catch(() => + noContext.flow.battle(noContext.view) + ); + expect(contextResult.info.map(v => v.code)).toContain(139); + await expect(contextResult.ret).resolves.toBeNull(); + + const noHero = createFixture(); + noHero.flow.bindContext(noHero.context); + const heroResult = modules.logger.catch(() => + noHero.flow.battle(noHero.view) + ); + expect(heroResult.info.map(v => v.code)).toContain(139); + await expect(heroResult.ret).resolves.toBeNull(); + + const noDamage = createFixture(); + noDamage.flow.bindContext(noDamage.context); + noDamage.flow.bindHero(noDamage.hero); + const damageResult = modules.logger.catch(() => + noDamage.flow.battle(noDamage.view) + ); + expect(damageResult.info.map(v => v.code)).toContain(139); + await expect(damageResult.ret).resolves.toBeNull(); + }); + + // 验证伤害上下文无法产出伤害信息时告警 141 并返回 null + it('warns 141 when the damage context cannot produce damage info', async () => { + const fixture = createFixture(); + fixture.flow.bindContext(fixture.context); + fixture.flow.bindHero(fixture.hero); + fixture.flow.bindDamage({ + state: fixture.state, + getDamageInfoByHandler: () => null + } as never); + + const result = modules.logger.catch(() => + fixture.flow.battle(fixture.view) + ); + + expect(result.info.map(v => v.code)).toContain(141); + await expect(result.ret).resolves.toBeNull(); + }); + + // 验证已知计算后怪物会转交给对应视图的 battle 流程 + it('delegates a known computed enemy to the view battle flow', async () => { + const fixture = createFixture(); + bindAll(fixture); + + const info = await fixture.flow.battleComputed(fixture.computed); + + expect(info).toBe(fixture.info); + }); +}); + +describe('CombatFlow async ordering', () => { + // 验证战前脚本 await 完成后才依次执行战前钩子、战后脚本与战后钩子 + it('awaits the before script before running hooks and after scripts', async () => { + const fixture = createFixture(); + bindAll(fixture); + const gate = createDeferred(); + const script = new FakeScript(1, 'script', fixture.calls); + script.gate = gate; + fixture.flow.addCombatScript(script); + fixture.flow + .addHook({ + onBeforeCombat: async () => { + fixture.calls.push('hooks.onBeforeCombat'); + }, + onAfterCombat: async () => { + fixture.calls.push('hooks.onAfterCombat'); + } + }) + .load(); + + const running = fixture.flow.battle(fixture.view); + await Promise.resolve(); + expect(fixture.calls).toEqual(['script.before']); + + gate.resolve(); + await running; + + expect(fixture.calls).toEqual([ + 'script.before', + 'hooks.onBeforeCombat', + 'script.after', + 'hooks.onAfterCombat' + ]); + }); + + // 验证战前脚本返回真值时短路,不再执行钩子与战后脚本 + it('short-circuits hooks and after scripts when before returns truthy', async () => { + const fixture = createFixture(); + bindAll(fixture); + const script = new FakeScript(1, 'script', fixture.calls, true); + fixture.flow.addCombatScript(script); + fixture.flow + .addHook({ + onBeforeCombat: async () => { + fixture.calls.push('hooks.onBeforeCombat'); + }, + onAfterCombat: async () => { + fixture.calls.push('hooks.onAfterCombat'); + } + }) + .load(); + + const info = await fixture.flow.battle(fixture.view); + + expect(info).toBe(fixture.info); + expect(fixture.calls).toEqual(['script.before']); + }); + + // 疑似 bug:接口约定战前脚本返回 false 应放弃战斗,详见 06-TEST-FINDINGS.md #06-01-3,修复后取消 skip + it.skip('abandons the battle when the before script returns false', async () => { + const fixture = createFixture(); + bindAll(fixture); + const script = new FakeScript(1, 'script', fixture.calls, false); + fixture.flow.addCombatScript(script); + fixture.flow + .addHook({ + onBeforeCombat: async () => { + fixture.calls.push('hooks.onBeforeCombat'); + } + }) + .load(); + + await fixture.flow.battle(fixture.view); + + expect(fixture.calls).toEqual(['script.before']); + }); +});