From c1781b81021e1b0ac415a4fc92e121474a153f8c Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Mon, 14 Sep 2026 15:43:59 +0800 Subject: [PATCH] test(06-05): cover HeroAttribute and hero modifier formulas (stage 1) - add attribute.test.ts: base/final, priority sort, delete, save flag, clone, codes 108/109 - add modifier.test.ts: value/percentage formulas, default priorities, setValue recompute - record suspected bug #06-05-1 (no-modifier final value stale) as it.skip --- .../data-base/src/hero/attribute.test.ts | 271 ++++++++++++++++++ .../data-base/src/hero/modifier.test.ts | 109 +++++++ 2 files changed, 380 insertions(+) create mode 100644 packages-user/data-base/src/hero/attribute.test.ts create mode 100644 packages-user/data-base/src/hero/modifier.test.ts diff --git a/packages-user/data-base/src/hero/attribute.test.ts b/packages-user/data-base/src/hero/attribute.test.ts new file mode 100644 index 0000000..aadaaab --- /dev/null +++ b/packages-user/data-base/src/hero/attribute.test.ts @@ -0,0 +1,271 @@ +// 测试 HeroAttribute 构件:基础/最终属性、修饰器增删排序、存盘开关、克隆与告警码 108/109 +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { BaseHeroModifier, HeroAttribute } from './attribute'; +import { logger } from '@motajs/common'; +import { type IHeroModifier } from './types'; + +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(); +}); + +interface TestTag { + id: string; +} + +interface TestHero { + hp: number; + atk: number; + tag: TestTag; +} + +/** 测试用数值修饰器,按优先级对数值属性做加法 */ +class TestModifier extends BaseHeroModifier { + readonly type = '@test/value'; + + constructor( + value: number, + readonly priority: number = 0 + ) { + super(value); + } + + modify(value: number): number { + return value + this.value; + } + + clone(): IHeroModifier { + return new TestModifier(this.value, this.priority); + } +} + +/** 测试用对象修饰器,故意返回同一引用以触发告警 109 */ +class SameRefModifier extends BaseHeroModifier { + readonly type = '@test/same-ref'; + readonly priority = 0; + + constructor() { + super({ id: 'same' }); + } + + modify(value: TestTag): TestTag { + return value; + } + + clone(): IHeroModifier { + return new SameRefModifier(); + } +} + +/** 构造一个带合成数值属性与对象属性的勇士属性对象 */ +function createAttribute(): HeroAttribute { + return new HeroAttribute({ + hp: 100, + atk: 10, + tag: { id: 'base' } + }); +} + +describe('HeroAttribute base and final values', () => { + // 验证存在修饰器时 set/add/mul/div 修改基础属性后最终属性同步反映 + it('reflects base attribute changes in the final attribute', () => { + const attribute = createAttribute(); + attribute.addModifier('hp', new TestModifier(0)); + attribute.addModifier('atk', new TestModifier(0)); + expect(attribute.getBaseAttribute('hp')).toBe(100); + expect(attribute.getFinalAttribute('hp')).toBe(100); + + attribute.set('hp', 40); + attribute.add('hp', 5); + expect(attribute.getBaseAttribute('hp')).toBe(45); + expect(attribute.getFinalAttribute('hp')).toBe(45); + + attribute.mul('atk', 3); + attribute.div('atk', 2); + expect(attribute.getBaseAttribute('atk')).toBe(15); + expect(attribute.getFinalAttribute('atk')).toBe(15); + }); + + // 疑似 bug:无修饰器时 recalculateAttribute 提前返回,基础属性变化不反映到最终属性,详见 06-TEST-FINDINGS.md #06-05-1 + it.skip('reflects base-only changes without any modifier', () => { + const attribute = createAttribute(); + attribute.set('hp', 40); + attribute.add('hp', 5); + + expect(attribute.getFinalAttribute('hp')).toBe(45); + }); + + // 验证未挂载修饰器时 getModifiers 返回空且 getModifierIndex 返回 -1 + it('returns safe defaults for attributes without modifiers', () => { + const attribute = createAttribute(); + expect([...attribute.getModifiers('hp')]).toEqual([]); + expect(attribute.getModifierIndex(new TestModifier(1))).toBe(-1); + }); +}); + +describe('HeroAttribute modifier management', () => { + // 验证 addModifier 按优先级降序排序并重算最终属性 + it('sorts modifiers by descending priority and recomputes the final value', () => { + const attribute = createAttribute(); + const low = new TestModifier(5, 0); + const high = new TestModifier(3, 10); + + attribute.addModifier('hp', low); + attribute.addModifier('hp', high); + + expect([...attribute.getModifiers('hp')]).toEqual([high, low]); + expect(attribute.getModifierIndex(high)).toBe(0); + expect(attribute.getModifierIndex(low)).toBe(1); + expect(attribute.getFinalAttribute('hp')).toBe(108); + }); + + // 验证 iterateModifiers 携带属性名输出每个已挂载修饰器 + it('iterates every attached modifier with its attribute name', () => { + const attribute = createAttribute(); + const modifier = new TestModifier(2); + attribute.addModifier('atk', modifier); + + expect([...attribute.iterateModifiers()]).toEqual([['atk', modifier]]); + }); + + // 验证重复添加已有归属的修饰器告警 108 且被忽略 + it('warns code 108 and ignores a modifier that already has an owner', () => { + const owner = createAttribute(); + const modifier = new TestModifier(7); + owner.addModifier('hp', modifier); + + const other = createAttribute(); + const result = logger.catch(() => other.addModifier('hp', modifier)); + + expect(result.info.map(info => info.code)).toContain(108); + expect([...other.getModifiers('hp')]).toEqual([]); + expect(other.getFinalAttribute('hp')).toBe(100); + }); + + // 验证按引用与按索引删除修饰器都正确移除并重算 + it('removes modifiers by reference and by index', () => { + const attribute = createAttribute(); + const first = new TestModifier(5, 10); + const second = new TestModifier(2, 0); + attribute.addModifier('hp', first); + attribute.addModifier('hp', second); + expect(attribute.getFinalAttribute('hp')).toBe(107); + + attribute.deleteModifier('hp', first); + expect(attribute.getFinalAttribute('hp')).toBe(102); + expect(attribute.getModifierIndex(second)).toBe(0); + + expect(attribute.deleteModifierByIndex('hp', 0)).toBe(second); + expect([...attribute.getModifiers('hp')]).toEqual([]); + expect(attribute.deleteModifierByIndex('hp', 0)).toBeNull(); + }); + + // 验证存盘开关可分别设置与查询 + it('tracks the per-modifier save flag', () => { + const attribute = createAttribute(); + const saved = new TestModifier(1); + attribute.addModifier('hp', saved); + expect(attribute.getModifierSaveEnabled(saved)).toBe(true); + attribute.setModifierSaveEnabled(saved, false); + expect(attribute.getModifierSaveEnabled(saved)).toBe(false); + + const unsaved = new TestModifier(1); + attribute.addModifier('atk', unsaved, false); + expect(attribute.getModifierSaveEnabled(unsaved)).toBe(false); + attribute.setModifierSaveEnabled(unsaved, true); + expect(attribute.getModifierSaveEnabled(unsaved)).toBe(true); + }); + + // 验证修饰器值变化只重算所属属性,未绑定修饰器被安全忽略 + it('recomputes only the owning attribute when a modifier value changes', () => { + const attribute = createAttribute(); + const modifier = new TestModifier(5); + attribute.addModifier('hp', modifier); + attribute.addModifier('atk', new TestModifier(3)); + expect(attribute.getFinalAttribute('hp')).toBe(105); + + modifier.setValue(20); + expect(attribute.getFinalAttribute('hp')).toBe(120); + expect(attribute.getFinalAttribute('atk')).toBe(13); + + attribute.markModifierDirty(new TestModifier(99)); + expect(attribute.getFinalAttribute('hp')).toBe(120); + }); +}); + +describe('HeroAttribute cloning and progress', () => { + // 验证仅复制基础属性、完整复制修饰器以及独立副本互不影响 + it('clones base only or with modifiers independently', () => { + const attribute = createAttribute(); + attribute.addModifier('hp', new TestModifier(5)); + + const baseOnly = attribute.clone({ cloneModifier: false }); + expect(baseOnly.getBaseAttribute('hp')).toBe(100); + expect(baseOnly.getFinalAttribute('hp')).toBe(100); + + const full = attribute.clone(); + expect(full.getFinalAttribute('hp')).toBe(105); + full.set('hp', 1); + expect(attribute.getBaseAttribute('hp')).toBe(100); + + const isolated = attribute.getModifiableClone(); + isolated.add('hp', 50); + expect(attribute.getBaseAttribute('hp')).toBe(100); + expect(isolated.getFinalAttribute('hp')).toBe(155); + + const structured = attribute.toStructured(); + structured.hp = 999; + expect(attribute.getBaseAttribute('hp')).toBe(100); + }); + + // 验证 catchCalculateProgress 输出计算过程且不修改最终属性 + it('iterates calculation progress without touching the final value', () => { + const attribute = createAttribute(); + const first = new TestModifier(5, 10); + const second = new TestModifier(2, 0); + attribute.addModifier('hp', first); + attribute.addModifier('hp', second); + + const progress = [...attribute.catchCalculateProgress('hp')]; + expect(progress.map(item => item[1])).toEqual([105, 107]); + expect(progress[0][0]).toBe(first); + expect(attribute.getFinalAttribute('hp')).toBe(107); + expect([...attribute.catchCalculateProgress('atk')]).toEqual([]); + }); + + // 验证对象修饰器返回同引用时告警 109 + it('warns code 109 when an object modifier returns the same reference', () => { + const attribute = createAttribute(); + const result = logger.catch(() => + attribute.addModifier('tag', new SameRefModifier()) + ); + + expect(result.info.map(info => info.code)).toContain(109); + expect(attribute.getFinalAttribute('tag')).toEqual({ id: 'base' }); + }); +}); diff --git a/packages-user/data-base/src/hero/modifier.test.ts b/packages-user/data-base/src/hero/modifier.test.ts new file mode 100644 index 0000000..c1d868f --- /dev/null +++ b/packages-user/data-base/src/hero/modifier.test.ts @@ -0,0 +1,109 @@ +// 测试勇士修饰器构件:数值/百分比公式、默认优先级、类型字符串与绑定重算 +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { HeroAttribute } from './attribute'; +import { PercentageModifier, ValueModifier } from './modifier'; + +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(); +}); + +interface TestHero { + hp: number; + atk: number; +} + +/** 构造一个带合成数值属性的勇士属性对象 */ +function createAttribute(): HeroAttribute { + return new HeroAttribute({ hp: 100, atk: 20 }); +} + +describe('ValueModifier formula', () => { + // 验证数值修饰器对当前值做加法并保留默认优先级与类型 + it('adds its value with the default priority and value type', () => { + const modifier = new ValueModifier(7); + + expect(modifier.modify(10)).toBe(17); + expect(modifier.priority).toBe(0); + expect(modifier.type).toBe('@system/value'); + expect(modifier.getValue()).toBe(7); + }); + + // 验证显式优先级被保留且克隆出的实例相互独立 + it('keeps an explicit priority and clones independently', () => { + const modifier = new ValueModifier(3, 25); + const cloned = modifier.clone(); + + expect(cloned.priority).toBe(25); + expect(cloned.getValue()).toBe(3); + + cloned.setValue(9); + expect(modifier.getValue()).toBe(3); + expect(cloned.getValue()).toBe(9); + }); +}); + +describe('PercentageModifier formula', () => { + // 验证百分比修饰器按基础属性比例加成并保留默认优先级与类型 + it('adds a percentage of the base value with the default priority', () => { + const modifier = new PercentageModifier(0.5); + + expect(modifier.modify(10, 100)).toBe(60); + expect(modifier.priority).toBe(10); + expect(modifier.type).toBe('@system/percentage'); + }); + + // 验证两个修饰器按优先级降序作用于最终属性,且 setValue 触发重算 + it('recomputes the bound attribute when the value changes', () => { + const attribute = createAttribute(); + const percent = new PercentageModifier(0.5); + const value = new ValueModifier(5); + attribute.addModifier('hp', percent); + attribute.addModifier('hp', value); + + expect(attribute.getFinalAttribute('hp')).toBe(155); + + value.setValue(20); + expect(attribute.getFinalAttribute('hp')).toBe(170); + + percent.setValue(1); + expect(attribute.getFinalAttribute('hp')).toBe(220); + }); + + // 验证 bindAttribute 可设置与清空修饰器归属 + it('binds and unbinds its owning attribute', () => { + const modifier = new ValueModifier(1); + const attribute = createAttribute(); + + modifier.bindAttribute(attribute); + expect(modifier.owner).toBe(attribute); + + modifier.bindAttribute(null); + expect(modifier.owner).toBeNull(); + }); +});