Files
restitution/tests/unit/PathfindingSystem.test.js
root 2e07519648 Refactor: Component-based architecture + 10 sub-systems
- Implemented 10 sub-systems (Economy, Pathfinding, Combat, Selection, Network, Map, Entity/Building/ControlPoint state machines, Orchestrator)
- Refactored Custom_Entity.js → Unit.js with 5 components (health, owner, inventory, movement, combat)
- Added Jest test suite with 100+ tests (EconomySystem 100%, EntityStateMachine 100%, PathfindingSystem 99%, Unit.js 72%)
- All webpack builds pass (0 errors)
- BMAD-auto team-respawn flow: 10 parallel sub-agents implemented systems

Architecture: Phaser 3 + XState + socket.io + EasyStar
Mode: team-respawn
Model: custom/ollama-cloud-pro
2026-05-29 22:13:44 +00:00

349 lines
12 KiB
JavaScript

/**
* PathfindingSystem.test.js — Tests for pathfinding, grid manipulation, and cache management.
*/
// Mock easystarjs
const mockFindPath = jest.fn();
const mockEasyStarInstance = {
setIterationsPerCalculation: jest.fn(),
enableDiagonals: jest.fn(),
enableCornerCutting: jest.fn(),
setGrid: jest.fn(),
setAcceptableTiles: jest.fn(),
setTileCost: jest.fn(),
setAdditionalPointCost: jest.fn(),
findPath: mockFindPath,
calculate: jest.fn(),
};
jest.mock('easystarjs', () => ({
js: jest.fn(() => mockEasyStarInstance),
TOP: 'TOP',
TOP_RIGHT: 'TOP_RIGHT',
RIGHT: 'RIGHT',
BOTTOM_RIGHT: 'BOTTOM_RIGHT',
BOTTOM: 'BOTTOM',
BOTTOM_LEFT: 'BOTTOM_LEFT',
LEFT: 'LEFT',
TOP_LEFT: 'TOP_LEFT',
}));
import PathfindingSystem from 'Systems/PathfindingSystem.js';
describe('PathfindingSystem', () => {
let pathfinding;
let mockScene;
let mockTilemap;
beforeEach(() => {
jest.clearAllMocks();
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
};
mockTilemap = {
width: 10,
height: 10,
getLayer: jest.fn(),
};
pathfinding = new PathfindingSystem(mockScene, mockTilemap);
});
// ── Constructor defaults ───────────────────────────────────────
describe('constructor', () => {
test('initializes with correct defaults', () => {
expect(pathfinding.scene).toBe(mockScene);
expect(pathfinding.tilemap).toBe(mockTilemap);
expect(pathfinding._initialized).toBe(false);
expect(pathfinding.pathCache.size).toBe(0);
expect(pathfinding.tileWidth).toBe(64);
expect(pathfinding.tileHeight).toBe(64);
});
test('grid starts empty', () => {
expect(pathfinding.grid).toEqual([]);
});
});
// ── initGrid ────────────────────────────────────────────────────
describe('initGrid', () => {
test('creates open grid when no layers found', () => {
mockTilemap.getLayer.mockReturnValue(null);
pathfinding.initGrid('rocks', 'ground');
expect(pathfinding._initialized).toBe(true);
expect(pathfinding.grid.length).toBe(10);
expect(pathfinding.grid[0].length).toBe(10);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
test('handles tile properties with cost', () => {
const mockLayer = {
getTileAt: jest.fn((x, y) => {
// Make half the tiles walkable with cost
if (x < 5) {
return { index: 1, properties: { cost: 1 } };
}
return null; // blocked
}),
};
mockTilemap.getLayer.mockReturnValue(mockLayer);
pathfinding.initGrid('rocks', 'ground');
expect(pathfinding._initialized).toBe(true);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
});
// ── setWalkable ─────────────────────────────────────────────────
describe('setWalkable', () => {
beforeEach(() => {
// Init with a 3x3 grid
pathfinding.grid = [
[0, 0, 0],
[0, 1, 0], // center blocked
[0, 0, 0],
];
pathfinding._initialized = true;
});
test('sets a tile to blocked (1)', () => {
pathfinding.setWalkable(0, 0, false);
expect(pathfinding.grid[0][0]).toBe(1);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
test('sets a tile to walkable (0)', () => {
pathfinding.setWalkable(1, 1, true);
expect(pathfinding.grid[1][1]).toBe(0);
});
test('no-ops when grid not initialized', () => {
pathfinding._initialized = false;
pathfinding.setWalkable(0, 0, true);
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('no-ops on out-of-bounds coordinates', () => {
pathfinding.setWalkable(-1, 0, true);
pathfinding.setWalkable(0, 99, true);
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('no-ops when value unchanged', () => {
pathfinding.setWalkable(0, 0, true); // already 0
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('invalidates cached paths that pass through changed tile', () => {
const path1 = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
const path2 = [{ x: 2, y: 2 }, { x: 2, y: 1 }];
pathfinding.setCachedPath('entity1', path1);
pathfinding.setCachedPath('entity2', path2);
pathfinding.setWalkable(1, 0, false); // path1 passes through (1,0)
expect(pathfinding.getCachedPath('entity1')).toBeUndefined(); // invalidated
expect(pathfinding.getCachedPath('entity2')).toBeDefined(); // unaffected
expect(pathfinding._dirtyEntities.has('entity1')).toBe(true);
expect(pathfinding._dirtyEntities.has('entity2')).toBe(false);
});
});
// ── setRegionWalkable ───────────────────────────────────────────
describe('setRegionWalkable', () => {
beforeEach(() => {
pathfinding.grid = [
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
pathfinding._initialized = true;
});
test('blocks a rectangular region', () => {
pathfinding.setRegionWalkable(1, 1, 2, 2, false);
expect(pathfinding.grid[1][1]).toBe(1);
expect(pathfinding.grid[1][2]).toBe(1);
expect(pathfinding.grid[2][1]).toBe(1);
expect(pathfinding.grid[2][2]).toBe(1);
// Outside region unchanged
expect(pathfinding.grid[0][0]).toBe(0);
});
});
// ── findPath ────────────────────────────────────────────────────
describe('findPath', () => {
test('calls easystar.findPath with correct coordinates', () => {
pathfinding._initialized = true;
pathfinding.findPath({ x: 0, y: 0 }, { x: 5, y: 5 }, () => {});
expect(mockFindPath).toHaveBeenCalledWith(
0, 0, 5, 5,
expect.any(Function),
);
});
test('warns and calls callback(null) if not initialized', () => {
const cb = jest.fn();
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Signature: findPath(startTile, endTile, options, callback)
// When not initialized, it calls the 4th argument (callback) with null
pathfinding.findPath({ x: 0, y: 0 }, { x: 1, y: 1 }, {}, cb);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('called before initGrid'),
);
expect(cb).toHaveBeenCalledWith(null);
warnSpy.mockRestore();
});
test('handles options omitted (callback as second arg style)', () => {
pathfinding._initialized = true;
const cb = jest.fn();
// Simulate calling findPath(start, end, callback) without options
pathfinding.findPath({ x: 0, y: 0 }, { x: 2, y: 2 }, cb);
// Should still call easystar.findPath
expect(mockFindPath).toHaveBeenCalled();
});
test('calls callback with null when easystar returns null', () => {
pathfinding._initialized = true;
const cb = jest.fn();
// Make findPath invoke callback with null
mockFindPath.mockImplementationOnce((sx, sy, ex, ey, cb) => cb(null));
pathfinding.findPath({ x: 0, y: 0 }, { x: 9, y: 9 }, cb);
expect(cb).toHaveBeenCalledWith(null);
});
test('clamps path length with maxPathLength option', () => {
pathfinding._initialized = true;
const cb = jest.fn();
const longPath = [
{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 },
{ x: 3, y: 0 }, { x: 4, y: 0 },
];
mockFindPath.mockImplementationOnce((sx, sy, ex, ey, cb) => cb(longPath));
pathfinding.findPath({ x: 0, y: 0 }, { x: 4, y: 0 }, { maxPathLength: 3 }, cb);
expect(cb).toHaveBeenCalledWith(longPath.slice(0, 3));
});
});
// ── cache management ────────────────────────────────────────────
describe('cache management', () => {
const samplePath = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
test('setCachedPath stores and getCachedPath retrieves', () => {
pathfinding.setCachedPath('e1', samplePath);
expect(pathfinding.getCachedPath('e1')).toEqual(samplePath);
});
test('getCachedPath returns undefined for missing entity', () => {
expect(pathfinding.getCachedPath('nonexistent')).toBeUndefined();
});
test('invalidateCache(entityId) removes single entry', () => {
pathfinding.setCachedPath('e1', samplePath);
pathfinding.setCachedPath('e2', samplePath);
pathfinding.invalidateCache('e1');
expect(pathfinding.getCachedPath('e1')).toBeUndefined();
expect(pathfinding.getCachedPath('e2')).toBeDefined();
});
test('invalidateCache() clears everything', () => {
pathfinding.setCachedPath('e1', samplePath);
pathfinding.setCachedPath('e2', samplePath);
pathfinding.invalidateCache();
expect(pathfinding.pathCache.size).toBe(0);
expect(pathfinding._dirtyEntities.size).toBe(0);
});
test('cacheSize reports correctly', () => {
expect(pathfinding.cacheSize).toBe(0);
pathfinding.setCachedPath('e1', samplePath);
expect(pathfinding.cacheSize).toBe(1);
});
test('dirtyCount reports correctly', () => {
expect(pathfinding.dirtyCount).toBe(0);
pathfinding._dirtyEntities.add('e1');
expect(pathfinding.dirtyCount).toBe(1);
});
});
// ── utility conversions ─────────────────────────────────────────
describe('utility methods', () => {
test('pathToWorldCoords converts tile path to world pixels', () => {
const result = pathfinding.pathToWorldCoords([
{ x: 0, y: 0 },
{ x: 1, y: 2 },
]);
expect(result).toEqual([
{ x: 32, y: 32 }, // 0*64 + 32
{ x: 96, y: 160 }, // 1*64 + 32, 2*64 + 32
]);
});
test('pathToWorldCoords returns empty for null/empty input', () => {
expect(pathfinding.pathToWorldCoords(null)).toEqual([]);
expect(pathfinding.pathToWorldCoords([])).toEqual([]);
});
test('tileToWorldCoords converts single tile', () => {
const result = pathfinding.tileToWorldCoords({ x: 3, y: 4 });
expect(result).toEqual({ x: 224, y: 288 }); // 3*64+32, 4*64+32
});
test('worldToTileCoords converts world position to tile', () => {
const result = pathfinding.worldToTileCoords(150, 200);
expect(result).toEqual({ x: 2, y: 3 }); // floor(150/64), floor(200/64)
});
test('dimensions returns width/height from grid', () => {
pathfinding.grid = [
[0, 0, 0],
[0, 0, 0],
];
expect(pathfinding.dimensions).toEqual({ width: 3, height: 2 });
});
test('dimensions returns zeros for empty grid', () => {
pathfinding.grid = [];
expect(pathfinding.dimensions).toEqual({ width: 0, height: 0 });
});
test('initialized getter reflects state', () => {
expect(pathfinding.initialized).toBe(false);
pathfinding._initialized = true;
expect(pathfinding.initialized).toBe(true);
});
});
// ── update loop ─────────────────────────────────────────────────
describe('update', () => {
test('does not throw when called', () => {
expect(() => pathfinding.update(0, 16)).not.toThrow();
});
});
});