test(06-05): cover HeroMover async movement and HeroRendering (stage 3)

- add mover.test.ts: config round-trip, Step/CannotMove/Hit, out-of-bound, terrain bypass, enter/leave order, code 144
- add rendering.test.ts: default alpha, setAlpha hook, hook unload
This commit is contained in:
unanmed 2026-09-14 15:55:08 +08:00
parent 6d5081abcb
commit 351111487a
2 changed files with 345 additions and 0 deletions

View File

@ -0,0 +1,255 @@
// 测试 HeroMover 异步移动:配置往返、Step/CannotMove/Hit、越界、地形忽略、enter/leave 顺序与码 144
import { afterAll, describe, expect, it, vi } from 'vitest';
import {
type IDataCommon,
Dir8FaceHandler,
FaceDirection,
ItemStore,
TileStore
} from '@user/data-common';
import { logger } from '@motajs/common';
import { type IPassPredicate } from '../map';
import { HeroLocation } from './location';
import { type IHeroMoveTopHandler, 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();
});
/** 构造一个仅含图块与道具存储的公共层假对象 */
function createState(): IDataCommon {
return {
tileStore: new TileStore(),
itemStore: new ItemStore()
} as never;
}
/** 构造一个停在原点的勇士位置对象,复用真实实现作为移动器宿主 */
function createTestTile(): HeroLocation {
return new HeroLocation(
createState(),
{ x: 0, y: 0, direction: FaceDirection.Down },
new Dir8FaceHandler()
);
}
/** 构造一个绑定在真实勇士位置对象上的移动器 */
function createMover(): HeroLocation['mover'] {
return createTestTile().mover;
}
/** 可配置结果的顶层移动实现,记录每次触发的顺序与信息对象 */
class FakeTopImpl implements IHeroMoveTopImpl {
/** 触发的行为序列 */
readonly calls: string[] = [];
/** 每次触发携带的信息对象 */
readonly handlers: IHeroMoveTopHandler[] = [];
/** 通行性判定结果 */
canPass: boolean = true;
/** 撞击判定结果 */
shouldHit: boolean = false;
/** 目标是否在地图范围内 */
bounded: boolean = true;
predicate(): IPassPredicate {
return {
canPass: () => this.canPass,
shouldHit: () => this.shouldHit
};
}
inBound(): boolean {
return this.bounded;
}
async enter(handler: IHeroMoveTopHandler): Promise<void> {
this.calls.push('enter');
this.handlers.push(handler);
}
async leave(handler: IHeroMoveTopHandler): Promise<void> {
this.calls.push('leave');
this.handlers.push(handler);
}
async hit(handler: IHeroMoveTopHandler): Promise<void> {
this.calls.push('hit');
this.handlers.push(handler);
}
async cannotEnter(handler: IHeroMoveTopHandler): Promise<void> {
this.calls.push('cannotEnter');
this.handlers.push(handler);
}
}
describe('HeroMover configuration', () => {
// 验证配置默认值以及 config 增量更新与链式返回
it('configures and reads back move options', () => {
const mover = createMover();
expect(mover.getConfig()).toEqual({
noRoute: false,
ignoreTerrain: false,
autoSave: false,
allowOutBound: false
});
expect(mover.config({ noRoute: true, autoSave: true })).toBe(mover);
expect(mover.getConfig()).toEqual({
noRoute: true,
ignoreTerrain: false,
autoSave: true,
allowOutBound: false
});
});
});
describe('HeroMover step codes', () => {
// 验证正常步进写入目标位置并按 before/current 顺序触发 leave 后 enter
it('steps onto the next tile and triggers leave before enter', async () => {
const mover = createMover();
const top = new FakeTopImpl();
mover.useTopImplementation(top);
mover.step(FaceDirection.Right);
const controller = mover.start();
expect(controller).not.toBeNull();
await controller!.onEnd;
expect(mover.tile.x).toBe(1);
expect(mover.tile.y).toBe(0);
expect(top.calls).toEqual(['leave', 'enter']);
expect(top.handlers[0].currLoc).toEqual({ x: 0, y: 0 });
expect(top.handlers[0].nextLoc).toEqual({ x: 1, y: 0 });
});
// 验证不可通行时停在原地并触发 cannotEnter
it('stops and triggers cannotEnter when the target is not passable', async () => {
const mover = createMover();
const top = new FakeTopImpl();
top.canPass = false;
mover.useTopImplementation(top);
mover.step(FaceDirection.Right);
const controller = mover.start();
await controller!.onEnd;
expect(mover.tile.x).toBe(0);
expect(top.calls).toEqual(['cannotEnter', 'leave', 'enter']);
});
// 验证撞击时停在原地并触发 hit
it('triggers hit and stops when the predicate reports an impact', async () => {
const mover = createMover();
const top = new FakeTopImpl();
top.shouldHit = true;
mover.useTopImplementation(top);
mover.step(FaceDirection.Down);
const controller = mover.start();
await controller!.onEnd;
expect(mover.tile.y).toBe(0);
expect(top.calls).toEqual(['hit', 'leave', 'enter']);
});
// 验证默认阻挡越界,开启 allowOutBound 后放行
it('blocks out-of-bound steps unless allowOutBound is enabled', async () => {
const mover = createMover();
const top = new FakeTopImpl();
top.bounded = false;
mover.useTopImplementation(top);
mover.step(FaceDirection.Right);
await mover.start()!.onEnd;
expect(mover.tile.x).toBe(0);
expect(top.calls).toEqual(['cannotEnter', 'leave', 'enter']);
top.calls.length = 0;
mover.config({ allowOutBound: true });
mover.step(FaceDirection.Right);
await mover.start()!.onEnd;
expect(mover.tile.x).toBe(1);
expect(top.calls).toEqual(['leave', 'enter']);
});
// 验证忽略地形时跳过通行/撞击判定与 enter/leave 触发
it('skips terrain checks when ignoreTerrain is enabled', async () => {
const mover = createMover();
const top = new FakeTopImpl();
top.canPass = false;
top.shouldHit = true;
mover.useTopImplementation(top);
mover.config({ ignoreTerrain: true });
mover.step(FaceDirection.Right);
await mover.start()!.onEnd;
expect(mover.tile.x).toBe(1);
expect(top.calls).toEqual([]);
});
// 验证传送步按边界规则执行或停止
it('handles teleport steps with the configured boundary rule', async () => {
const mover = createMover();
const top = new FakeTopImpl();
mover.useTopImplementation(top);
mover.tp(3, 4);
await mover.start()!.onEnd;
expect(mover.tile.x).toBe(3);
expect(mover.tile.y).toBe(4);
top.calls.length = 0;
top.bounded = false;
mover.config({ allowOutBound: true });
mover.tp(9, 9);
await mover.start()!.onEnd;
expect(mover.tile.x).toBe(3);
expect(mover.tile.y).toBe(4);
expect(top.calls).toEqual(['leave', 'enter']);
});
});
describe('HeroMover top implementation guard', () => {
// 验证缺少顶层实现时告警 144 且不移动
it('warns code 144 and stops without a top implementation', async () => {
const mover = createMover();
mover.step(FaceDirection.Right);
const result = logger.catch(() => mover.start());
const controller = result.ret;
expect(controller).not.toBeNull();
await controller!.onEnd;
expect(result.info.map(info => info.code)).toContain(144);
expect(mover.tile.x).toBe(0);
});
});

View File

@ -0,0 +1,90 @@
// 测试 HeroRendering 构件:默认不透明度、setAlpha 与 onSetAlpha 钩子注册/解除
import { afterAll, describe, expect, it, vi } from 'vitest';
import { type IDataCommon, ItemStore, TileStore } from '@user/data-common';
import { HeroRendering } from './rendering';
import { type IHeroRenderingHooks } 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;
}
/** 记录 onSetAlpha 调用序列的测试钩子 */
class FakeRenderingHook implements Partial<IHeroRenderingHooks> {
readonly alphas: number[] = [];
onSetAlpha(alpha: number): void {
this.alphas.push(alpha);
}
}
describe('HeroRendering alpha', () => {
// 验证构造后不透明度为满值并持有公共层对象
it('starts at full opacity with the provided state', () => {
const state = createState();
const rendering = new HeroRendering(state);
expect(rendering.alpha).toBe(1);
expect(rendering.state).toBe(state);
});
// 验证 setAlpha 更新不透明度并触发已加载的 onSetAlpha 钩子
it('updates alpha and notifies the onSetAlpha hook', () => {
const rendering = new HeroRendering(createState());
const hook = new FakeRenderingHook();
rendering.addHook(hook).load();
rendering.setAlpha(0.25);
expect(rendering.alpha).toBe(0.25);
expect(hook.alphas).toEqual([0.25]);
});
// 验证钩子控制器解除后不再收到 onSetAlpha
it('stops notifying after the hook controller unloads', () => {
const rendering = new HeroRendering(createState());
const hook = new FakeRenderingHook();
const controller = rendering.addHook(hook);
controller.load();
rendering.setAlpha(0.5);
controller.unload();
rendering.setAlpha(0.1);
expect(rendering.alpha).toBe(0.1);
expect(hook.alphas).toEqual([0.5]);
});
});