fix(08-07): align path graph/system tests and apply authorized D-10 fixes

- graph.ts: seed start node into mapped set (B1) and restore 174 NaN/negative cost guard (B2)
- graph.test.ts: drop useMapState/DirectionMapper, inject Dir4/Dir8 handlers, reach genuine out-of-map branch and assert 183
- system.test.ts: drop finder.useMapState, inject Dir4FaceHandler, adapt to IPathfindingResult
This commit is contained in:
unanmed 2026-10-05 18:49:18 +08:00
parent 4208857eff
commit b62b394ee9
3 changed files with 85 additions and 70 deletions

View File

@ -11,7 +11,6 @@ import {
type IPassCheckHandler,
type IPassPredicate
} from '@user/data-base';
import { InternalDirectionGroup } from '@motajs/common';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
@ -34,9 +33,9 @@ interface TestModules {
MapState: typeof import('@user/data-base').MapState;
TileStore: typeof import('@user/data-common').TileStore;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir4FaceHandler: typeof import('@user/data-common').Dir4FaceHandler;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
logger: typeof import('@motajs/common').logger;
}
@ -54,9 +53,9 @@ beforeAll(async () => {
MapState: baseModule.MapState,
TileStore: commonModule.TileStore,
FaceManager: commonModule.FaceManager,
Dir4FaceHandler: commonModule.Dir4FaceHandler,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
RoleFaceBinder: commonModule.RoleFaceBinder,
DirectionMapper: motaModule.DirectionMapper,
logger: motaModule.logger
};
});
@ -260,10 +259,9 @@ function createFixture(
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);
const maps = new modules.MapState(commonState);
const map = maps.fromRaw({
floorId: 'F1',
width,
@ -271,10 +269,10 @@ function createFixture(
layerAlias: { 0: 'event' },
events: { 0: {} }
});
const layer = map!.getLayerByAlias('event')!;
const layer = map!.eventLayer!;
const builder = new modules.PathfindingGraphBuilder();
builder.useMapState(maps);
builder.useMapLayer(layer);
builder.useFaceHandler(new modules.Dir4FaceHandler());
if (predicate) {
builder.usePassPredicate(predicate);
}
@ -282,43 +280,36 @@ function createFixture(
}
describe('pathfinding graph building', () => {
// 验证未注入谓词时无可通行边,BFS 仅包含起始位置自身且无损失告警
it('includes only the start node when no predicate is injected', () => {
const { map, builder } = createFixture(
// 验证仅注入方向处理器而未注入谓词时,构建入口告警缺失谓词守卫码 173 并返回 null
it('warns the missing-predicate guard code and returns null without a predicate', () => {
const { builder } = createFixture(
[1, 1, 1, 1, 1, 1, 1, 1, 1],
3,
null
);
builder.useMapLayer(map.getLayerByAlias('event'));
const graph = builder.build({ x: 1, y: 1 });
const result = modules.logger.catch(() =>
builder.build({ x: 1, y: 1 })
);
expect(graph.width).toBe(3);
expect(graph.height).toBe(3);
expect(graph.nodes.size).toBe(1);
const start = graph.nodes.get(1 * 3 + 1)!;
expect(start.edges).toHaveLength(0);
expect(start.terminal).toBe(false);
expect(start.cost).toBe(1);
expect(result.ret).toBeNull();
expect(result.info.map(info => info.code)).toContain(173);
});
// 验证注入谓词后中心节点邻域方向数与 DirectionMapper 四正交组一致为 4
// 验证默认注入 Dir4FaceHandler 时中心节点拥有四条正交邻域边
it('resolves four orthogonal neighbor edges for the center node', () => {
const { map, builder } = createFixture(
[1, 1, 1, 1, 1, 1, 1, 1, 1],
3,
null
);
const mapper = new modules.DirectionMapper();
const expected = [...mapper.map(InternalDirectionGroup.Dir4)];
const predicate = new FixturePredicate(
map,
new modules.Dir8FaceHandler()
);
builder.usePassPredicate(predicate);
const graph = builder.build({ x: 1, y: 1 });
const graph = builder.build({ x: 1, y: 1 })!;
const center = graph.nodes.get(1 * 3 + 1)!;
expect(center.edges).toHaveLength(expected.length);
expect(center.edges).toHaveLength(4);
const dirs = new Set(center.edges.map(edge => edge.dir));
expect(dirs).toEqual(
@ -331,8 +322,8 @@ describe('pathfinding graph building', () => {
);
});
// 验证 useDirGroup 注入八方向组后中心节点拥有 8 条邻域边
it('expands neighbor edges to eight when Dir8 group is injected', () => {
// 验证注入 Dir8FaceHandler 后中心节点拥有 8 条邻域边
it('expands neighbor edges to eight when the Dir8 handler is injected', () => {
const { map, builder } = createFixture(
[1, 1, 1, 1, 1, 1, 1, 1, 1],
3,
@ -343,8 +334,8 @@ describe('pathfinding graph building', () => {
new modules.Dir8FaceHandler()
);
builder.usePassPredicate(predicate);
builder.useFaceHandler(InternalDirectionGroup.Dir8);
const graph = builder.build({ x: 1, y: 1 });
builder.useFaceHandler(new modules.Dir8FaceHandler());
const graph = builder.build({ x: 1, y: 1 })!;
const center = graph.nodes.get(1 * 3 + 1)!;
expect(center.edges).toHaveLength(8);
@ -358,7 +349,7 @@ describe('pathfinding graph building', () => {
new modules.Dir8FaceHandler()
);
builder.usePassPredicate(predicate);
const graph = builder.build({ x: 0, y: 0 });
const graph = builder.build({ x: 0, y: 0 })!;
const source = graph.nodes.get(0)!;
const sink = graph.nodes.get(1)!;
@ -375,7 +366,7 @@ describe('pathfinding graph building', () => {
new modules.Dir8FaceHandler()
);
builder.usePassPredicate(predicate);
const graph = builder.build({ x: 0, y: 0 });
const graph = builder.build({ x: 0, y: 0 })!;
const hit = graph.nodes.get(1)!;
expect(hit.terminal).toBe(true);
@ -387,8 +378,8 @@ describe('pathfinding graph building', () => {
expect(graph.nodes.get(2)!.terminal).toBe(false);
});
// 验证图层未绑定时构建入口告警新码 173 并返回空图而非异常
it('warns the registered code and returns an empty graph without layer', () => {
// 验证未绑定图层时构建入口告警守卫码 173 并返回 null
it('warns the registered code and returns null without layer', () => {
const builder = new modules.PathfindingGraphBuilder();
builder.useMapLayer(null);
@ -396,21 +387,27 @@ describe('pathfinding graph building', () => {
builder.build({ x: 0, y: 0 })
);
expect(result.ret.nodes.size).toBe(0);
expect(result.ret).toBeNull();
expect(result.info.map(info => info.code)).toContain(173);
});
// 验证起始位置越界时构建入口同样告警新码 173 并返回空图
it('warns the registered code and returns an empty graph when start is out of map', () => {
const { builder } = createFixture([1, 1, 1, 1, 1, 1, 1, 1, 1], 3, null);
// 验证谓词与方向处理器齐备而起始位置越界时,构建入口告警越界守卫码 183 并返回 null
it('warns the out-of-map guard code and returns null when start is out of map', () => {
const { map, builder } = createFixture(
[1, 1, 1, 1, 1, 1, 1, 1, 1],
3,
null
);
builder.usePassPredicate(
new FixturePredicate(map, new modules.Dir8FaceHandler())
);
const result = modules.logger.catch(() =>
builder.build({ x: 3, y: 0 })
);
expect(result.ret.nodes.size).toBe(0);
expect(result.ret.width).toBe(0);
expect(result.info.map(info => info.code)).toContain(173);
expect(result.ret).toBeNull();
expect(result.info.map(info => info.code)).toContain(183);
});
// 验证墙体隔断区域不进入有向图:图仅包含从起始位置沿可通行边可达的节点
@ -423,7 +420,7 @@ describe('pathfinding graph building', () => {
builder.usePassPredicate(
new FixturePredicate(map, new modules.Dir8FaceHandler())
);
const graph = builder.build({ x: 0, y: 0 });
const graph = builder.build({ x: 0, y: 0 })!;
expect(graph.nodes.size).toBe(3);
expect(graph.nodes.has(0)).toBe(true);
@ -447,7 +444,7 @@ describe('pathfinding graph building', () => {
builder.useCostFunction(block =>
block.locator.x === 1 && block.locator.y === 1 ? 7 : 3
);
const graph = builder.build({ x: 1, y: 1 });
const graph = builder.build({ x: 1, y: 1 })!;
expect(graph.nodes.get(1 * 3 + 1)!.cost).toBe(7);
expect(graph.nodes.get(0)!.cost).toBe(3);
@ -475,7 +472,7 @@ describe('pathfinding graph building', () => {
);
expect(result.info.map(info => info.code)).not.toContain(174);
expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe(
expect(result.ret!.nodes.get(1 * 3 + 1)!.cost).toBe(
Number.POSITIVE_INFINITY
);
});
@ -499,7 +496,7 @@ describe('pathfinding graph building', () => {
);
expect(result.info.filter(info => info.code === 174)).toHaveLength(1);
expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe(1);
expect(result.ret!.nodes.get(1 * 3 + 1)!.cost).toBe(1);
});
// 验证负数损失同样在构建时告警新码 174 并回退为损失 1
@ -521,6 +518,6 @@ describe('pathfinding graph building', () => {
);
expect(result.info.filter(info => info.code === 174)).toHaveLength(1);
expect(result.ret.nodes.get(1 * 3 + 1)!.cost).toBe(1);
expect(result.ret!.nodes.get(1 * 3 + 1)!.cost).toBe(1);
});
});

View File

@ -18,7 +18,11 @@ import {
type IPassPredicate
} from '@user/data-base';
import { type PathfindingSystem } from '../system';
import { type IPathfindingStep, type PathCostFunction } from '../types';
import {
PathfindingStatus,
type IPathfindingStep,
type PathCostFunction
} from '../types';
vi.hoisted(() => {
vi.stubGlobal('main', { replayChecking: true });
@ -42,8 +46,8 @@ interface TestModules {
TileStore: typeof import('@user/data-common').TileStore;
ObjectMover: typeof import('@user/data-common').ObjectMover;
FaceManager: typeof import('@user/data-common').FaceManager;
Dir4FaceHandler: typeof import('@user/data-common').Dir4FaceHandler;
Dir8FaceHandler: typeof import('@user/data-common').Dir8FaceHandler;
DirectionMapper: typeof import('@motajs/common').DirectionMapper;
RoleFaceBinder: typeof import('@user/data-common').RoleFaceBinder;
logger: typeof import('@motajs/common').logger;
}
@ -63,8 +67,8 @@ beforeAll(async () => {
TileStore: commonModule.TileStore,
ObjectMover: commonModule.ObjectMover,
FaceManager: commonModule.FaceManager,
Dir4FaceHandler: commonModule.Dir4FaceHandler,
Dir8FaceHandler: commonModule.Dir8FaceHandler,
DirectionMapper: motaModule.DirectionMapper,
RoleFaceBinder: commonModule.RoleFaceBinder,
logger: motaModule.logger
};
@ -321,10 +325,9 @@ function createSystem(rows: number[], width: number): SystemFixture {
eventStore: {},
roleFace: new modules.RoleFaceBinder(),
faceManager,
directionMapper: new modules.DirectionMapper(),
saveSystem: {}
} as never;
const maps = new modules.MapState(tileStore, commonState);
const maps = new modules.MapState(commonState);
const map = maps.fromRaw({
floorId: 'F1',
width,
@ -332,12 +335,12 @@ function createSystem(rows: number[], width: number): SystemFixture {
layerAlias: { 0: 'event' },
events: { 0: {} }
})!;
const layer = map.getLayerByAlias('event')!;
const layer = map.eventLayer!;
const system = new modules.PathfindingSystem(commonState as never);
const tile = createTestTile();
system.useMover(tile.mover);
system.finder.useMapState(maps);
system.finder.useMapLayer(layer);
system.finder.useFaceHandler(new modules.Dir4FaceHandler());
return { map, layer, system, tile };
}
@ -359,12 +362,13 @@ describe('pathfinding system', () => {
const fixture = createSystem([1, 1, 1, 1, 1, 1, 1, 1, 1], 3);
injectPredicate(fixture);
const steps = fixture.system.finder.find(
const result = fixture.system.finder.find(
{ x: 0, y: 0 },
{ x: 2, y: 0 }
);
expect(steps).toEqual([
expect(result.status).toBe(PathfindingStatus.Success);
expect(result.path).toEqual([
{
dir: FaceDirection.Right,
from: { x: 0, y: 0 },
@ -386,13 +390,14 @@ describe('pathfinding system', () => {
block.locator.x === 1 && block.locator.y === 1 ? 10 : 1;
fixture.system.finder.useCostFunction(cost);
const steps = fixture.system.finder.find(
const result = fixture.system.finder.find(
{ x: 0, y: 1 },
{ x: 2, y: 1 }
);
expect(steps).toHaveLength(4);
for (const step of steps) {
expect(result.status).toBe(PathfindingStatus.Success);
expect(result.path).toHaveLength(4);
for (const step of result.path) {
expect(step.to).not.toEqual({ x: 1, y: 1 });
}
});
@ -410,7 +415,8 @@ describe('pathfinding system', () => {
);
expect(result.info.map(info => info.code)).toContain(174);
expect(result.ret).toHaveLength(2);
expect(result.ret.status).toBe(PathfindingStatus.Success);
expect(result.ret.path).toHaveLength(2);
});
// 验证 Infinity 是合法损失值:不告警且路径绕开高损失格
@ -428,8 +434,9 @@ describe('pathfinding system', () => {
);
expect(result.info.map(info => info.code)).not.toContain(174);
expect(result.ret).toHaveLength(4);
for (const step of result.ret) {
expect(result.ret.status).toBe(PathfindingStatus.Success);
expect(result.ret.path).toHaveLength(4);
for (const step of result.ret.path) {
expect(step.to).not.toEqual({ x: 1, y: 1 });
}
});
@ -447,8 +454,10 @@ describe('pathfinding system', () => {
);
const path = fixture.system.getPath({ x: 2, y: 1 });
expect(steps).toEqual([]);
expect(path).toEqual([]);
expect(steps.status).toBe(PathfindingStatus.NoPath);
expect(steps.path).toEqual([]);
expect(path.status).toBe(PathfindingStatus.NoPath);
expect(path.path).toEqual([]);
expect(fixture.tile.x).toBe(0);
expect(fixture.tile.y).toBe(0);
expect(fixture.tile.setPosCalls).toEqual([]);
@ -463,8 +472,9 @@ describe('pathfinding system', () => {
{ x: 0, y: 0 },
{ x: 2, y: 0 }
);
expect(detour).toHaveLength(4);
for (const step of detour) {
expect(detour.status).toBe(PathfindingStatus.Success);
expect(detour.path).toHaveLength(4);
for (const step of detour.path) {
expect(step.to).not.toEqual({ x: 1, y: 0 });
}
@ -472,7 +482,8 @@ describe('pathfinding system', () => {
{ x: 0, y: 0 },
{ x: 1, y: 0 }
);
expect(direct).toEqual([
expect(direct.status).toBe(PathfindingStatus.Success);
expect(direct.path).toEqual([
{
dir: FaceDirection.Right,
from: { x: 0, y: 0 },
@ -489,7 +500,8 @@ describe('pathfinding system', () => {
const result = fixture.system.moveTo({ x: 2, y: 0 });
expect(result).not.toBeNull();
expect(result!.path).toHaveLength(2);
expect(result!.path.status).toBe(PathfindingStatus.Success);
expect(result!.path.path).toHaveLength(2);
await result!.controller.onEnd;
expect(fixture.tile.x).toBe(2);
expect(fixture.tile.y).toBe(0);
@ -579,9 +591,11 @@ describe('pathfinding system', () => {
system.getPath({ x: 1, y: 0 })
);
expect(findResult.ret).toEqual([]);
expect(findResult.ret.status).toBe(PathfindingStatus.InvalidInput);
expect(findResult.ret.path).toEqual([]);
expect(findResult.info.map(info => info.code)).toContain(173);
expect(pathResult.ret).toEqual([]);
expect(pathResult.ret.status).toBe(PathfindingStatus.InvalidInput);
expect(pathResult.ret.path).toEqual([]);
expect(pathResult.info.map(info => info.code)).toContain(173);
});

View File

@ -55,11 +55,13 @@ export class MapGraphBuilder implements IMapGraphBuilder {
* @param block 位置信息
*/
private resolveCost(block: ILayerLocation): number {
if (this.cost) {
return this.cost(block);
} else {
const cost = this.cost ? this.cost(block) : 1;
// Infinity 是合法损失,仅 NaN 或负数视为非法并回退为单位损失
if (Number.isNaN(cost) || cost < 0) {
logger.warn(174);
return 1;
}
return cost;
}
build(start: ITileLocator): IMapGraph | null {
@ -91,6 +93,8 @@ export class MapGraphBuilder implements IMapGraphBuilder {
const mapped: Set<number> = new Set();
const startIndex = indexer.locatorToIndex(start);
adjacency.set(startIndex, []);
// 起始节点自身没有入边,须先登记进映射集否则会漏出图
mapped.add(startIndex);
// 以起始位置为中心 BFS,仅沿可通行有向边扩展,不可达区域不入图
const queue: ITileLocator[] = [start];