// 测试 followers 组合行为:增删与钩子、邻居链接、同步/异步聚集与码 142 import { afterAll, describe, expect, it, vi } from 'vitest'; import { type IDataCommon, Dir8FaceHandler, FaceDirection, ItemStore, SaveCompression, 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 ( 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 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 }); // 录像系统桩,仅用于满足移动时的 route.add 记录 const replaySystem = { route: { add: vi.fn() } }; return { tileStore, itemStore, replaySystem } 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 {} async leave(): Promise {} async hit(): Promise {} async cannotEnter(): Promise {} } 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); }); }); describe('HeroFollowersController same-reference restore (#06-17-6)', () => { // 验证同索引同图块数字的跟随者在原地读档,且不触发增删钩子 it('keeps the follower instance when the index and number match', () => { const env = createController(); const added: number[] = []; const removed: number[] = []; env.controller .addHook({ onAddFollower: (_follower, index) => added.push(index), onRemoveFollower: (_follower, index) => removed.push(index) }) .load(); const follower = env.controller.addFollower(100); follower.location.setPos(2, 3); follower.rendering.setAlpha(0.5); const saved = [follower.saveState(SaveCompression.NoCompression)]; follower.location.setPos(9, 9); follower.rendering.setAlpha(1); env.controller.restoreFollowers(saved, SaveCompression.NoCompression); expect(env.controller.getFollower(0)).toBe(follower); expect(follower.location.x).toBe(2); expect(follower.location.y).toBe(3); expect(follower.rendering.alpha).toBe(0.5); expect(added).toEqual([0]); expect(removed).toEqual([]); }); // 验证同索引但图块数字不同的槽位替换实例并通知移除与新增钩子 it('replaces a slot with a different number and notifies both hooks', () => { const env = createController(); const added: number[] = []; const removed: number[] = []; env.controller .addHook({ onAddFollower: (_follower, index) => added.push(index), onRemoveFollower: (_follower, index) => removed.push(index) }) .load(); const follower = env.controller.addFollower(100); const save = follower.saveState(SaveCompression.NoCompression); env.controller.restoreFollowers( [{ ...save, num: 101 }], SaveCompression.NoCompression ); expect(env.controller.getFollower(0)).not.toBe(follower); expect(env.controller.getFollower(0)!.num).toBe(101); expect(added).toEqual([0, 0]); expect(removed).toEqual([0]); }); // 验证存档短于现有列表时末尾跟随者被删除并通知移除钩子 it('deletes trailing followers absent from the save', () => { const env = createController(); const removed: number[] = []; env.controller .addHook({ onRemoveFollower: (_follower, index) => removed.push(index) }) .load(); const first = env.controller.addFollower(100); env.controller.addFollower(100); env.controller.restoreFollowers( [first.saveState(SaveCompression.NoCompression)], SaveCompression.NoCompression ); expect(env.controller.getAllFollowers()).toEqual([first]); expect(removed).toEqual([1]); }); // 验证存档长于现有列表时按索引补齐新跟随者并通知新增钩子 it('appends followers missing from the live list', () => { const env = createController(); const added: number[] = []; env.controller .addHook({ onAddFollower: (_follower, index) => added.push(index) }) .load(); const first = env.controller.addFollower(100); const save = first.saveState(SaveCompression.NoCompression); env.controller.restoreFollowers( [save, { ...save }], SaveCompression.NoCompression ); const all = env.controller.getAllFollowers(); expect(all[0]).toBe(first); expect(all.length).toBe(2); expect(added).toEqual([0, 1]); }); });