test(06-05): cover hero assembly and item/equipment pipeline (stage 2)

- add location/state tests: locator, floor/pos hooks, subsystem assembly, code 116, changeFloor hook order
- add equipment/equipStore/items tests: equip/unequip/replace, compareEquip, instance sorting, item routing, code 146
- add follower tests: add/remove hooks, neighbour links, sync/async gather, code 142
- record suspected bugs #06-05-2/#06-05-3 (named-slot empty check, unreachable code 147) as it.skip
This commit is contained in:
unanmed 2026-09-14 15:52:32 +08:00
parent c1781b8102
commit 6d5081abcb
6 changed files with 1382 additions and 0 deletions

View File

@ -0,0 +1,237 @@
// 测试 HeroEquipsStore 构件:实例增删计数、按 uid/排序器排序与 EquipmentState 修饰器生成
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IHeroAttr,
type IItemRawData,
ItemCategory,
ItemStore,
TileStore,
TileType
} from '@user/data-common';
import { HeroEquipsStore } from './equipStore';
import { PercentageModifier, ValueModifier } from './modifier';
import { type IEquipmentSortHandler, type IEquipmentSorter } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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();
});
type HeroKey = SelectKey<IHeroAttr, number>;
interface TestEnv {
state: IDataCommon;
tileStore: IDataCommon['tileStore'];
itemStore: IDataCommon['itemStore'];
store: HeroEquipsStore<IHeroAttr>;
}
/** 构造一个仅含图块与道具存储的公共层假对象 */
function createState(): IDataCommon {
return {
tileStore: new TileStore(),
itemStore: new ItemStore()
} as never;
}
/** 构造一个装配装备实例存储的测试环境 */
function createEnv(): TestEnv {
const state = createState();
return {
state,
tileStore: state.tileStore,
itemStore: state.itemStore,
store: new HeroEquipsStore<IHeroAttr>(state)
};
}
/** 构造一个带合成装备属性的道具定义 */
function createItem(
num: number,
id: string,
value: [HeroKey, number][] = [],
percentage: [HeroKey, number][] = []
): IItemRawData<IHeroAttr> {
return {
num,
id,
category: ItemCategory.Equipment,
name: id,
text: id,
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots: [0],
animate: 'sword',
value: new Map(value),
percentage: new Map(percentage),
loadEvent: null,
unloadEvent: null
}
};
}
/** 向图块与道具存储注册一个装备道具定义 */
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
env.tileStore.addTile({
num: item.num,
id: item.id,
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
env.itemStore.addItem(item);
}
/** 一个按 uid 降序排列的测试排序器 */
class FakeSorter implements IEquipmentSorter<IHeroAttr> {
compare(handler: IEquipmentSortHandler<IHeroAttr>): number {
return handler.equipB.uid - handler.equipA.uid;
}
}
describe('HeroEquipsStore instances', () => {
// 验证 add 分配自增 uid,未知图块或缺失定义返回 -1
it('allocates increasing uids and rejects unknown items', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword'));
env.tileStore.addTile({
num: 12,
id: 'ghost',
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
expect(env.store.add(10)).toBe(0);
expect(env.store.add('sword')).toBe(1);
expect(env.store.add(99)).toBe(-1);
expect(env.store.add('missing')).toBe(-1);
expect(env.store.add(12)).toBe(-1);
});
// 验证 get/count/delete 按 uid 或图块查询并计数
it('gets, counts and deletes instances', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword'));
const first = env.store.add(10);
env.store.add(10);
expect(env.store.get(first)?.uid).toBe(first);
expect(env.store.get(99)).toBeNull();
expect(env.store.count(10)).toBe(2);
expect(env.store.count('sword')).toBe(2);
expect(env.store.count(99)).toBe(0);
env.store.delete(first);
expect(env.store.get(first)).toBeNull();
expect(env.store.count(10)).toBe(1);
});
// 验证 instancesOf 只输出指定图块实例,未知图块返回空数组
it('lists instances filtered by item', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword'));
registerItem(env, createItem(11, 'axe'));
const swordA = env.store.add(10);
const axe = env.store.add(11);
const swordB = env.store.add(10);
expect(env.store.instancesOf(10).map(state => state.uid)).toEqual([
swordA,
swordB
]);
expect(env.store.instancesOf('axe').map(state => state.uid)).toEqual([
axe
]);
expect(env.store.instancesOf(99)).toEqual([]);
});
// 验证无排序器时按 uid 升序,使用排序器后按其顺序且并列回退 uid
it('orders instances by uid by default and by the sorter when set', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword'));
const first = env.store.add(10);
const second = env.store.add(10);
const third = env.store.add(10);
expect(env.store.instances().map(state => state.uid)).toEqual([
first,
second,
third
]);
env.store.useSorter(new FakeSorter());
expect(env.store.instances().map(state => state.uid)).toEqual([
third,
second,
first
]);
expect(env.store.instancesOf(10).map(state => state.uid)).toEqual([
third,
second,
first
]);
env.store.useSorter({ compare: () => 0 });
expect(env.store.instances().map(state => state.uid)).toEqual([
first,
second,
third
]);
env.store.useSorter(null);
expect(env.store.instances().map(state => state.uid)).toEqual([
first,
second,
third
]);
});
});
describe('EquipmentState modifiers', () => {
// 验证装备实例按定义生成数值与百分比修饰器
it('builds value and percentage modifiers from the item', () => {
const env = createEnv();
const item = createItem(10, 'sword', [['atk', 5]], [['hp', 0.2]]);
registerItem(env, item);
const uid = env.store.add(10);
const state = env.store.get(uid)!;
const modifiers = [...state.getModifiers()];
expect(state.uid).toBe(uid);
expect(state.item).toBe(item);
expect(modifiers.map(item => item[0])).toEqual(['atk', 'hp']);
expect(modifiers[0][1]).toBeInstanceOf(ValueModifier);
expect(modifiers[1][1]).toBeInstanceOf(PercentageModifier);
});
});

View File

@ -0,0 +1,334 @@
// 测试 HeroEquipment 组合行为:槽位判定、装备/替换/卸下、属性修饰器联动、compareEquip 与码 146/147
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IHeroAttr,
type IItemRawData,
ItemCategory,
ItemStore,
TileStore,
TileType
} from '@user/data-common';
import { logger } from '@motajs/common';
import { HeroAttribute } from './attribute';
import { HeroEquipsStore } from './equipStore';
import { HeroEquipment } from './equipment';
import { EquipStatus } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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();
});
type HeroKey = SelectKey<IHeroAttr, number>;
interface TestEnv {
state: IDataCommon;
tileStore: IDataCommon['tileStore'];
itemStore: IDataCommon['itemStore'];
store: HeroEquipsStore<IHeroAttr>;
equipment: HeroEquipment<IHeroAttr>;
attribute: HeroAttribute<IHeroAttr>;
}
/** 构造一份合成的勇士基础属性 */
function createBaseAttr(): IHeroAttr {
return {
name: 'hero',
hp: 100,
hpmax: 100,
atk: 10,
def: 5,
mdef: 0,
mana: 0,
manamax: 0,
money: 0,
exp: 0
};
}
/** 构造一个带合成装备属性与修饰器的装备道具定义 */
function createItem(
num: number,
id: string,
slots: (number | string)[] = [0],
value: [HeroKey, number][] = [],
percentage: [HeroKey, number][] = []
): IItemRawData<IHeroAttr> {
return {
num,
id,
category: ItemCategory.Equipment,
name: id,
text: id,
hideInToolbox: false,
effect: { useEvent: null, useEffect: () => {}, canUse: () => false },
equip: {
slots,
animate: 'sword',
value: new Map(value),
percentage: new Map(percentage),
loadEvent: null,
unloadEvent: null
}
};
}
/** 构造一个装配勇士装备对象的测试环境 */
function createEnv(): TestEnv {
const tileStore = new TileStore();
const itemStore = new ItemStore<IHeroAttr, unknown>();
const state = { tileStore, itemStore } as never;
const attribute = new HeroAttribute<IHeroAttr>(createBaseAttr());
const store = new HeroEquipsStore<IHeroAttr>(state);
const equipment = new HeroEquipment<IHeroAttr>(store, attribute);
return { state, tileStore, itemStore, store, equipment, attribute };
}
/** 向图块与道具存储注册一个装备道具定义 */
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
env.tileStore.addTile({
num: item.num,
id: item.id,
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
env.itemStore.addItem(item);
}
describe('HeroEquipment slots', () => {
// 验证装备槽名称可设置与读取
it('sets equipment slot names', () => {
const env = createEnv();
env.equipment.setSlots(['weapon', 'armor']);
expect(env.equipment.slots).toEqual(['weapon', 'armor']);
});
// 验证数值槽与名称槽的三种装备判定结果
it('classifies equip status for numeric and named slots', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0, 'weapon'], [['atk', 5]]));
env.equipment.setSlots(['weapon']);
const uid = env.store.add(10);
expect(env.equipment.canEquipTo(uid, 0)).toBe(EquipStatus.CanEquip);
expect(env.equipment.canEquipTo(uid, 'weapon')).toBe(
EquipStatus.CanEquip
);
expect(env.equipment.canEquipTo(uid, 5)).toBe(EquipStatus.CannotEquip);
expect(env.equipment.canEquipTo(999, 0)).toBe(EquipStatus.CannotEquip);
env.equipment.equip(uid, 0);
expect(env.equipment.canEquipTo(uid, 0)).toBe(EquipStatus.NeedReplace);
expect(env.equipment.canEquipTo(uid, 'weapon')).toBe(
EquipStatus.NeedReplace
);
registerItem(env, createItem(11, 'armor', ['armor'], [['def', 2]]));
const armorUid = env.store.add(11);
expect(env.equipment.canEquipTo(armorUid, 'armor')).toBe(
EquipStatus.CannotEquip
);
});
});
describe('HeroEquipment equip and unequip', () => {
// 验证装备到空槽返回 undefined 并应用数值修饰器
it('equips into an empty slot and applies modifiers', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
env.equipment.setSlots(['weapon']);
const uid = env.store.add(10);
expect(env.equipment.equip(uid, 0)).toBeUndefined();
expect(env.equipment.getEquipped(0)).toBe(uid);
expect(env.equipment.equipped(uid)).toBe(true);
expect(env.attribute.getFinalAttribute('atk')).toBe(15);
});
// 验证替换占用槽返回旧 uid 并换上新装备的修饰器
it('replaces the occupant and swaps its modifiers', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
registerItem(env, createItem(11, 'axe', [0], [['atk', 12]]));
env.equipment.setSlots(['weapon']);
const sword = env.store.add(10);
const axe = env.store.add(11);
env.equipment.equip(sword, 0);
expect(env.equipment.equip(axe, 0)).toBe(sword);
expect(env.equipment.getEquipped(0)).toBe(axe);
expect(env.equipment.equipped(sword)).toBe(false);
expect(env.attribute.getFinalAttribute('atk')).toBe(22);
});
// 验证同一件装备在启用自动卸下时移动到新的名称槽
it('moves an already equipped item when autoUnload is enabled', () => {
const env = createEnv();
registerItem(
env,
createItem(10, 'sword', ['weapon', 'armor'], [['atk', 5]])
);
env.equipment.setSlots(['weapon', 'armor']);
const uid = env.store.add(10);
env.equipment.equip(uid, 'weapon');
expect(env.equipment.equip(uid, 'armor')).toBeUndefined();
expect(env.equipment.getEquipped(0)).toBeUndefined();
expect(env.equipment.getEquipped(1)).toBe(uid);
});
// 验证关闭自动卸下或重复装备同一槽位时保持原状
it('keeps the previous slot when autoUnload is disabled', () => {
const env = createEnv();
registerItem(
env,
createItem(10, 'sword', ['weapon', 'armor'], [['atk', 5]])
);
env.equipment.setSlots(['weapon', 'armor']);
const uid = env.store.add(10);
env.equipment.equip(uid, 'weapon');
expect(env.equipment.equip(uid, 'armor', false)).toBeUndefined();
expect(env.equipment.getEquipped(0)).toBe(uid);
expect(env.equipment.getEquipped(1)).toBeUndefined();
expect(env.equipment.equip(uid, 'weapon')).toBeUndefined();
expect(env.equipment.getEquipped(0)).toBe(uid);
});
// 验证卸下返回被移除的 uid 并移除修饰器,空槽返回 undefined
it('unequips a slot and removes its modifiers', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
env.equipment.setSlots(['weapon']);
const uid = env.store.add(10);
env.equipment.equip(uid, 0);
expect(env.equipment.unequip(0)).toBe(uid);
expect(env.equipment.getEquipped(0)).toBeUndefined();
expect(env.equipment.equipped(uid)).toBe(false);
expect(env.attribute.getFinalAttribute('atk')).toBe(10);
expect(env.equipment.unequip(0)).toBeUndefined();
});
// 验证 getEquips 按槽位顺序输出装备状态或空位
it('lists slots in order with their equipment states', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0, 1], [['atk', 5]]));
env.equipment.setSlots(['weapon', 'armor']);
const uid = env.store.add(10);
env.equipment.equip(uid, 0);
const equips = env.equipment.getEquips();
expect(equips).toHaveLength(2);
expect(equips[0]).toBe(env.store.get(uid));
expect(equips[1]).toBeNull();
});
// 疑似 bug:字符串槽位空槽判断条件写反导致总是替换首个匹配槽位,详见 06-TEST-FINDINGS.md #06-05-2
it.skip('uses the first empty named slot instead of replacing an occupant', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', ['weapon'], [['atk', 5]]));
registerItem(env, createItem(11, 'axe', ['weapon'], [['atk', 12]]));
env.equipment.setSlots(['weapon', 'weapon']);
const sword = env.store.add(10);
const axe = env.store.add(11);
env.equipment.equip(sword, 'weapon');
env.equipment.equip(axe, 'weapon');
expect(env.equipment.getEquipped(0)).toBe(sword);
expect(env.equipment.getEquipped(1)).toBe(axe);
});
// 疑似 bug:码 147 因 canEquipTo 先返回 CannotEquip 而不可达,详见 06-TEST-FINDINGS.md #06-05-3
it.skip('warns code 147 when no equipment slot is available', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', ['weapon'], [['atk', 5]]));
env.equipment.setSlots([]);
const uid = env.store.add(10);
const result = logger.catch(() => env.equipment.equip(uid, 'weapon'));
expect(result.info.map(info => info.code)).toContain(147);
});
});
describe('HeroEquipment compare and guards', () => {
// 验证 compareEquip 逐属性输出两个装备的最终属性差
it('diffs the final attributes of two equipment instances', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
registerItem(env, createItem(11, 'axe', [0], [['def', 3]]));
env.equipment.setSlots(['weapon']);
const sword = env.store.add(10);
const axe = env.store.add(11);
const diff = env.equipment.compareEquip(sword, axe, 0);
expect(Object.keys(diff).sort()).toEqual(['atk', 'def']);
expect(diff.atk).toBe(5);
expect(diff.def).toBe(-3);
});
// 验证装备实例缺失时卸下告警 146
it('warns code 146 when the equipped instance is missing', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
env.equipment.setSlots(['weapon']);
const uid = env.store.add(10);
env.equipment.equip(uid, 0);
env.store.delete(uid);
const result = logger.catch(() => env.equipment.unequip(0));
expect(result.ret).toBeUndefined();
expect(result.info.map(info => info.code)).toContain(146);
});
// 验证比较未知 uid 时告警 146 并返回空差异对象
it('warns code 146 when comparing an unknown uid', () => {
const env = createEnv();
registerItem(env, createItem(10, 'sword', [0], [['atk', 5]]));
const uid = env.store.add(10);
const result = logger.catch(() =>
env.equipment.compareEquip(uid, 999, 0)
);
expect(result.ret).toEqual({});
expect(result.info.map(info => info.code)).toContain(146);
});
});

View File

@ -0,0 +1,266 @@
// 测试 followers 组合行为:增删与钩子、邻居链接、同步/异步聚集与码 142
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
Dir8FaceHandler,
FaceDirection,
ItemStore,
TileStore,
TileType
} from '@user/data-common';
import { logger } from '@motajs/common';
import { type IPassPredicate } from '../map';
import { HeroFollowersController } from './follower';
import { HeroLocation } from './location';
import { type IHeroMoveTopImpl } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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 TestEnv {
state: IDataCommon;
location: HeroLocation;
controller: HeroFollowersController;
}
/** 构造一个注册了跟随者图块的公共层假对象 */
function createState(): IDataCommon {
const tileStore = new TileStore();
const itemStore = new ItemStore();
tileStore.addTile({
num: 100,
id: 'ghost',
events: {},
type: TileType.Npc,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
return { tileStore, itemStore } as never;
}
/** 构造一个停在原点的勇士位置对象 */
function createLocation(
state: IDataCommon,
faceHandler: Dir8FaceHandler
): HeroLocation {
return new HeroLocation(
state,
{ x: 0, y: 0, direction: FaceDirection.Down },
faceHandler
);
}
/** 构造一个跟随者控制器及其勇士位置对象 */
function createController(): TestEnv {
const state = createState();
const faceHandler = new Dir8FaceHandler();
const location = createLocation(state, faceHandler);
return {
state,
location,
controller: new HeroFollowersController(state, location, faceHandler)
};
}
/** 一个始终允许通行的顶层移动实现,用于驱动跟随者实际移动 */
class FakeTopImpl implements IHeroMoveTopImpl {
predicate(): IPassPredicate {
return { canPass: () => true, shouldHit: () => false };
}
inBound(): boolean {
return true;
}
async enter(): Promise<void> {}
async leave(): Promise<void> {}
async hit(): Promise<void> {}
async cannotEnter(): Promise<void> {}
}
describe('HeroFollowersController members', () => {
// 验证 addFollower 在勇士位置追加并触发 onAddFollower
it('appends followers at the hero locator and notifies the add hook', () => {
const env = createController();
const added: [number, number][] = [];
env.controller
.addHook({
onAddFollower: (follower, index) => {
added.push([follower.num, index]);
}
})
.load();
env.location.setPos(2, 3);
env.location.mover.setFaceDir(FaceDirection.Up);
const first = env.controller.addFollower(100);
const second = env.controller.addFollower('ghost');
expect(first.num).toBe(100);
expect(second.num).toBe(100);
expect(first.location.x).toBe(2);
expect(first.location.y).toBe(3);
expect(first.location.getCurrentFaceDirection()).toBe(FaceDirection.Up);
expect(first.rendering.alpha).toBe(1);
expect(added).toEqual([
[100, 0],
[100, 1]
]);
expect(env.controller.getAllFollowers()).toEqual([first, second]);
});
// 验证按索引与按数字/字符串 id 查询跟随者
it('queries followers by index and by id', () => {
const env = createController();
const first = env.controller.addFollower(100);
const second = env.controller.addFollower(100);
expect(env.controller.getFollower(0)).toBe(first);
expect(env.controller.getFollower(1)).toBe(second);
expect(env.controller.getFollower(5)).toBeNull();
expect(
[...env.controller.getFollowersById(100)].map(i => i[0])
).toEqual([0, 1]);
expect(
[...env.controller.getFollowersById('ghost')].map(i => i[0])
).toEqual([0, 1]);
expect([...env.controller.getFollowersById(999)]).toEqual([]);
});
// 验证 next/last 返回相邻跟随者且边界为 null
it('links neighbours through next and last', () => {
const env = createController();
const first = env.controller.addFollower(100);
const second = env.controller.addFollower(100);
const third = env.controller.addFollower(100);
expect(first.next()).toBe(second);
expect(second.next()).toBe(third);
expect(third.next()).toBeNull();
expect(third.last()).toBe(second);
expect(first.last()).toBeNull();
});
// 验证移除单个或全部跟随者都会触发 onRemoveFollower
it('removes one or all followers and notifies the remove hook', async () => {
const env = createController();
const removed: number[] = [];
env.controller
.addHook({
onRemoveFollower: (_follower, index) => {
removed.push(index);
}
})
.load();
env.controller.addFollower(100);
const second = env.controller.addFollower(100);
await env.controller.removeFollower(0);
expect(env.controller.getAllFollowers()).toEqual([second]);
expect(removed).toEqual([0]);
await env.controller.removeFollower(9);
expect(env.controller.getAllFollowers()).toEqual([second]);
await env.controller.removeAllFollowers();
expect(env.controller.getAllFollowers()).toEqual([]);
expect(removed).toEqual([0, 0]);
});
});
describe('HeroFollowersController gathering', () => {
// 验证同步聚集把跟随者吸附到勇士位置并同步朝向
it('gathers followers synchronously onto the hero', () => {
const env = createController();
const gathered: boolean[] = [];
env.controller
.addHook({
onGatherFollowers: sync => {
gathered.push(sync);
}
})
.load();
const follower = env.controller.addFollower(100);
env.location.setPos(5, 6);
env.location.mover.setFaceDir(FaceDirection.Right);
env.controller.gatherFollowersSync();
expect(follower.location.x).toBe(5);
expect(follower.location.y).toBe(6);
expect(follower.location.getCurrentFaceDirection()).toBe(
FaceDirection.Right
);
expect(gathered).toEqual([true]);
});
// 验证异步聚集等待移动结束并按勇士移动方向跟进一步
it('gathers followers asynchronously and waits for the movement', async () => {
const env = createController();
const follower = env.controller.addFollower(100);
const gathered: boolean[] = [];
env.controller
.addHook({
onGatherFollowers: sync => {
gathered.push(sync);
}
})
.load();
env.location.mover.useTopImplementation(new FakeTopImpl());
follower.location.mover.useTopImplementation(new FakeTopImpl());
env.location.mover.step(FaceDirection.Right);
const controller = env.location.mover.start();
expect(controller).not.toBeNull();
await controller!.onEnd;
expect(env.location.x).toBe(1);
await env.controller.gatherFollowers();
expect(follower.location.x).toBe(1);
expect(follower.location.y).toBe(0);
expect(gathered).toEqual([false]);
});
// 验证未知字符串跟随者 id 告警 142 并退回数字 0
it('warns code 142 for an unknown string follower id', () => {
const env = createController();
const result = logger.catch(() =>
env.controller.addFollower('missing')
);
expect(result.info.map(info => info.code)).toContain(142);
expect(result.ret.num).toBe(0);
});
});

View File

@ -0,0 +1,235 @@
// 测试 HeroItems 构件:常量/消耗计数、装备路由、拾取效果、未知输入与 useItem 分类行为
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IHeroAttr,
type IItemRawData,
ItemCategory,
ItemStore,
TileStore,
TileType
} from '@user/data-common';
import { HeroItems } from './items';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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 TestEnv {
state: IDataCommon;
tileStore: IDataCommon['tileStore'];
itemStore: IDataCommon['itemStore'];
items: HeroItems<IHeroAttr>;
}
interface ItemFixture {
item: IItemRawData<IHeroAttr>;
useEffect: ReturnType<typeof vi.fn>;
canUse: ReturnType<typeof vi.fn>;
}
/** 构造一个装配勇士道具对象的测试环境 */
function createEnv(): TestEnv {
const tileStore = new TileStore();
const itemStore = new ItemStore<IHeroAttr, unknown>();
const state = { tileStore, itemStore } as never;
return {
state,
tileStore,
itemStore,
items: new HeroItems<IHeroAttr>(state)
};
}
/** 构造一个带可观测效果的内联道具定义 */
function createItem(
num: number,
id: string,
category: ItemCategory,
allowed: boolean = true
): ItemFixture {
const useEffect = vi.fn();
const canUse = vi.fn(() => allowed);
const item: IItemRawData<IHeroAttr> = {
num,
id,
category,
name: id,
text: id,
hideInToolbox: false,
effect: { useEvent: null, useEffect, canUse },
equip: {
slots: [0],
animate: 'sword',
value: new Map(),
percentage: new Map(),
loadEvent: null,
unloadEvent: null
}
};
return { item, useEffect, canUse };
}
/** 向图块与道具存储注册一个道具定义 */
function registerItem(env: TestEnv, item: IItemRawData<IHeroAttr>): void {
env.tileStore.addTile({
num: item.num,
id: item.id,
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
env.itemStore.addItem(item);
}
describe('HeroItems counting', () => {
// 验证常量道具叠加计数并在数量归零时删除
it('increments constant items and deletes them at zero', () => {
const env = createEnv();
registerItem(env, createItem(20, 'key', ItemCategory.Constant).item);
env.items.addItem(20, 2);
expect(env.items.itemCount(20)).toBe(2);
expect(env.items.getItemState(20)?.id).toBe('key');
env.items.addItem(20, 3);
expect(env.items.itemCount(20)).toBe(5);
env.items.addItem(20, -5);
expect(env.items.itemCount(20)).toBe(0);
expect(env.items.getItemState(20)).toBeNull();
});
// 验证 getItem 等价于增加一个道具并支持字符串 id
it('gets one item through getItem and string ids', () => {
const env = createEnv();
registerItem(env, createItem(20, 'key', ItemCategory.Constant).item);
env.items.getItem('key');
expect(env.items.itemCount(20)).toBe(1);
expect(env.items.itemCount('key')).toBe(1);
});
// 验证装备类道具被路由到装备实例存储
it('routes equipment items into the equipment store', () => {
const env = createEnv();
registerItem(env, createItem(30, 'sword', ItemCategory.Equipment).item);
env.items.addItem(30, 2);
expect(env.items.equipment.count(30)).toBe(2);
expect(env.items.itemCount(30)).toBe(0);
});
// 验证拾取类道具按数量逐个触发效果且不占用背包
it('triggers pick effects once per added item', () => {
const env = createEnv();
const fixture = createItem(40, 'coin', ItemCategory.Pick);
registerItem(env, fixture.item);
env.items.addItem(40, 3);
expect(fixture.useEffect).toHaveBeenCalledTimes(3);
expect(env.items.itemCount(40)).toBe(0);
});
// 验证未知图块、缺失定义与无效 id 都安全忽略
it('ignores unknown tiles and missing item definitions', () => {
const env = createEnv();
env.tileStore.addTile({
num: 77,
id: 'ghost',
events: {},
type: TileType.Item,
pass: { onlyEvents: false, inPass: 15, outPass: 15 },
eventPass: true
});
env.items.addItem(99);
env.items.getItem('missing');
env.items.addItem(77, 3);
expect(env.items.itemCount(99)).toBe(0);
expect(env.items.itemCount(77)).toBe(0);
});
});
describe('HeroItems useItem', () => {
// 验证 useItem 仅对常量与可用的消耗类生效,并对装备/拾取/缺失返回 false
it('uses constant and consumable items only when allowed', () => {
const env = createEnv();
const constant = createItem(20, 'key', ItemCategory.Constant);
const consumable = createItem(21, 'potion', ItemCategory.Consumable);
const blocked = createItem(
22,
'sealed',
ItemCategory.Consumable,
false
);
const equipment = createItem(30, 'sword', ItemCategory.Equipment);
const pick = createItem(40, 'coin', ItemCategory.Pick);
for (const fixture of [
constant,
consumable,
blocked,
equipment,
pick
]) {
registerItem(env, fixture.item);
}
expect(env.items.useItem(99)).toBe(false);
env.items.addItem(20);
expect(env.items.useItem(20)).toBe(true);
expect(constant.useEffect).toHaveBeenCalledTimes(1);
expect(env.items.itemCount(20)).toBe(1);
env.items.addItem(21, 2);
expect(env.items.useItem(21)).toBe(true);
expect(env.items.itemCount(21)).toBe(1);
expect(env.items.useItem(21)).toBe(true);
expect(env.items.itemCount(21)).toBe(0);
expect(env.items.getItemState(21)).toBeNull();
env.items.addItem(22);
expect(env.items.useItem(22)).toBe(false);
expect(blocked.useEffect).not.toHaveBeenCalled();
env.items.addItem(30);
expect(env.items.useItem(30)).toBe(false);
expect(env.items.equipment.count(30)).toBe(1);
env.items.addItem(40);
expect(env.items.useItem(40)).toBe(false);
expect(pick.useEffect).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,123 @@
// 测试 HeroLocation 构件:初始坐标/楼层、朝向一致性以及位置与楼层钩子
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
Dir8FaceHandler,
FaceDirection,
ItemStore,
TileStore
} from '@user/data-common';
import { HeroLocation } from './location';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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();
});
/** 构造一个仅包含图块与道具存储的公共层假对象 */
function createState(): IDataCommon {
return {
tileStore: new TileStore(),
itemStore: new ItemStore()
} as never;
}
/** 构造一个八方向朝向处理器 */
function createFaceHandler(): Dir8FaceHandler {
return new Dir8FaceHandler();
}
/** 构造一个停在指定定位器上的勇士位置对象 */
function createLocation(): HeroLocation {
return new HeroLocation(
createState(),
{ x: 3, y: 4, direction: FaceDirection.Right },
createFaceHandler()
);
}
describe('HeroLocation position and floor', () => {
// 验证初始坐标、未定楼层与朝向来自构造时的定位器
it('initializes position, face direction and an undefined floor', () => {
const location = createLocation();
expect(location.x).toBe(3);
expect(location.y).toBe(4);
expect(location.floorId).toBeUndefined();
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Right);
expect(location.mover.faceDirection).toBe(FaceDirection.Right);
});
// 验证 setPos 更新坐标并触发 onSetPos 钩子
it('updates the position and notifies the onSetPos hook', () => {
const location = createLocation();
const calls: [number, number][] = [];
location
.addHook({
onSetPos: (x, y) => {
calls.push([x, y]);
}
})
.load();
location.setPos(7, 9);
expect(location.x).toBe(7);
expect(location.y).toBe(9);
expect(calls).toEqual([[7, 9]]);
});
// 验证 setFloor 更新楼层并触发 onSetFloor 钩子
it('updates the floor and notifies the onSetFloor hook', () => {
const location = createLocation();
const floors: (string | undefined)[] = [];
location
.addHook({
onSetFloor: floorId => {
floors.push(floorId);
}
})
.load();
location.setFloor('F2');
expect(location.floorId).toBe('F2');
location.setFloor(undefined);
expect(location.floorId).toBeUndefined();
expect(floors).toEqual(['F2', undefined]);
});
// 验证朝向经由共享移动器读写
it('reports the face direction through the shared mover', () => {
const location = createLocation();
location.mover.setFaceDir(FaceDirection.Down);
expect(location.getCurrentFaceDirection()).toBe(FaceDirection.Down);
});
});

View File

@ -0,0 +1,187 @@
// 测试 HeroState 装配:子系统装配、属性视图、修饰器注册与 changeFloor 钩子顺序
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
type IHeroAttr,
Dir8FaceHandler,
FaceDirection,
ItemStore,
TileStore
} from '@user/data-common';
import { logger } from '@motajs/common';
import { HeroAttribute } from './attribute';
import { ValueModifier } from './modifier';
import { HeroState } from './state';
import { type IHeroAttribute, type IHeroState } from './types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
vi.stubGlobal('location', { origin: 'http://localhost' });
Map.prototype.getOrInsert ??= function <K, V>(
this: Map<K, V>,
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 <K, V>(
this: Map<K, V>,
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();
});
/** 构造一个仅包含图块与道具存储的公共层假对象 */
function createState(): IDataCommon {
return {
tileStore: new TileStore(),
itemStore: new ItemStore()
} as never;
}
/** 构造一个八方向朝向处理器 */
function createFaceHandler(): Dir8FaceHandler {
return new Dir8FaceHandler();
}
/** 构造一份合成的勇士基础属性 */
function createBaseAttr(): IHeroAttr {
return {
name: 'hero',
hp: 100,
hpmax: 100,
atk: 10,
def: 5,
mdef: 0,
mana: 0,
manamax: 0,
money: 0,
exp: 0
};
}
/** 构造一个装配完成的勇士状态对象,可注入既有属性对象 */
function createHeroState(
attribute?: IHeroAttribute<IHeroAttr>
): IHeroState<IHeroAttr> {
return new HeroState<IHeroAttr>(
createState(),
createFaceHandler(),
attribute ?? new HeroAttribute<IHeroAttr>(createBaseAttr())
);
}
describe('HeroState assembly', () => {
// 验证构造器装配全部子系统并停在默认定位器
it('assembles every subsystem at the default locator', () => {
const hero = createHeroState();
expect(hero.location).toBeDefined();
expect(hero.rendering.alpha).toBe(1);
expect(hero.followers.getAllFollowers()).toEqual([]);
expect(hero.items.equipment).toBeDefined();
expect(hero.equip.slots).toEqual([]);
expect(hero.getLocation()).toEqual({
x: 0,
y: 0,
direction: FaceDirection.Down
});
});
// 验证可修改属性返回共享对象、只读视图共享引用、独立属性为克隆
it('exposes shared, readonly and isolated attribute views', () => {
const attribute = new HeroAttribute<IHeroAttr>(createBaseAttr());
const hero = createHeroState(attribute);
expect(hero.getModifiableAttribute()).toBe(attribute);
expect(hero.getAttribute()).toBe(attribute);
const isolated = hero.getIsolatedAttribute();
expect(isolated).not.toBe(attribute);
isolated.set('hp', 1);
expect(attribute.getBaseAttribute('hp')).toBe(100);
});
// 验证 attachAttribute 替换绑定的属性对象
it('replaces the bound attribute through attachAttribute', () => {
const hero = createHeroState();
const replacement = new HeroAttribute<IHeroAttr>(createBaseAttr());
hero.attachAttribute(replacement);
expect(hero.getModifiableAttribute()).toBe(replacement);
expect(hero.getAttribute()).toBe(replacement);
});
});
describe('HeroState modifier registry', () => {
// 验证已注册类型可创建并插入修饰器,未知类型告警 116 并返回 null
it('creates registered modifiers and warns code 116 for unknown types', () => {
const hero = createHeroState();
hero.registerModifier('@system/value', () => new ValueModifier(5));
expect(hero.createModifier('@system/value')).not.toBeNull();
expect(
hero.createAndInsertModifier('@system/value', 'hp')
).not.toBeNull();
expect(hero.getModifiableAttribute().getFinalAttribute('hp')).toBe(105);
const unknown = logger.catch(() =>
hero.createAndInsertModifier('@missing/type', 'hp')
);
expect(unknown.ret).toBeNull();
expect(unknown.info.map(info => info.code)).toContain(116);
});
});
describe('HeroState changeFloor', () => {
// 验证 changeFloor 依次触发 before、位置/楼层更新与 after 钩子
it('changes floor with the documented hook order', async () => {
const hero = createHeroState();
const calls: string[] = [];
hero.addHook({
onBeforeChangeFloor: async () => {
calls.push('before');
},
onAfterChangeFloor: async () => {
calls.push('after');
}
}).load();
hero.location
.addHook({
onSetFloor: () => {
calls.push('floor');
}
})
.load();
await hero.changeFloor({
target: 'F2',
x: 5,
y: 6,
face: FaceDirection.Up
});
expect(calls).toEqual(['before', 'floor', 'after']);
expect(hero.getLocation()).toEqual({
x: 5,
y: 6,
direction: FaceDirection.Up
});
expect(hero.location.floorId).toBe('F2');
expect(hero.location.mover.moveDirection).toBe(FaceDirection.Up);
});
});