- 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
349 lines
12 KiB
JavaScript
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();
|
|
});
|
|
});
|
|
});
|