Files
restitution/gameServer/tests/PathfindingService.test.ts
kaykayyali 3fc29f728e feat: Colyseus authoritative server + invite-code lobby
- Replace socket.io relay with Colyseus 0.15 authoritative server
- GameRoom with GameState schema (players, units, resources)
- Pure TS services: CombatResolver, EconomyService, PathfindingService, UnitManager
- POST /api/create-room → 4-char invite code
- React/MUI LobbyScreen: Create (shows code + START GAME) / Join by code
- ColyseusClient: joinOrCreate/join by room type = invite code
- Nginx: static assets direct, all else proxied to Colyseus (WS upgrade)
- Content-hashed JS bundles for Cloudflare cache-busting
- 1-player lobbies: START GAME button bypasses 2-player wait
2026-05-30 02:49:20 +00:00

301 lines
8.9 KiB
TypeScript

/**
* PathfindingService.test.ts — Tests for server-side pathfinding service.
*
* Tests: obstacle avoidance, valid/invalid moves, straight-line paths.
*/
// ── 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(),
avoidAdditionalPoint: jest.fn(),
stopAvoidingAdditionalPoint: jest.fn(),
stopAvoidingAllAdditionalPoints: jest.fn(),
enableSync: jest.fn(),
disableSync: jest.fn(),
disableDiagonals: jest.fn(),
disableCornerCutting: jest.fn(),
setDirectionalCondition: jest.fn(),
removeAllDirectionalConditions: jest.fn(),
removeAdditionalPointCost: jest.fn(),
removeAllAdditionalPointCosts: jest.fn(),
cancelPath: jest.fn(),
};
jest.mock("easystarjs", () => ({
js: jest.fn(() => mockEasyStarInstance),
}));
import { PathfindingService } from "../src/systems/PathfindingService";
// Helper: build a grid with a wall (blocked cells = 1) for obstacle tests
function gridWithWall(
width: number,
height: number,
wallX: number,
wallY: number,
wallLength: number,
): number[][] {
const grid: number[][] = [];
for (let y = 0; y < height; y++) {
const row: number[] = [];
for (let x = 0; x < width; x++) {
// Block a vertical wall at wallX, from wallY to wallY+wallLength-1
if (x === wallX && y >= wallY && y < wallY + wallLength) {
row.push(1);
} else {
row.push(0);
}
}
grid.push(row);
}
return grid;
}
describe("PathfindingService", () => {
let service: PathfindingService;
beforeEach(() => {
jest.clearAllMocks();
service = new PathfindingService(10, 10);
});
// ── Constructor ─────────────────────────────────────────────────
describe("constructor", () => {
test("creates an EasyStar instance with correct config", () => {
expect(mockEasyStarInstance.setIterationsPerCalculation).toHaveBeenCalledWith(1000);
expect(mockEasyStarInstance.enableDiagonals).toHaveBeenCalled();
expect(mockEasyStarInstance.enableCornerCutting).toHaveBeenCalled();
});
});
// ── setGrid ─────────────────────────────────────────────────────
describe("setGrid", () => {
test("sets the grid and acceptable tiles on EasyStar", () => {
const grid = [
[0, 0],
[0, 1],
];
service.setGrid(grid);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(grid);
expect(mockEasyStarInstance.setAcceptableTiles).toHaveBeenCalledWith([0]);
});
});
// ── setWalkable ─────────────────────────────────────────────────
describe("setWalkable", () => {
test("marks a tile as blocked (1) and updates EasyStar grid", () => {
const grid = [
[0, 0],
[0, 0],
];
service.setGrid(grid);
service.setWalkable(0, 0, false);
// Should call setGrid with updated grid
const expectedGrid = [
[1, 0],
[0, 0],
];
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(expectedGrid);
});
test("marks a tile as walkable (0)", () => {
const grid = [
[1, 0],
[0, 0],
];
service.setGrid(grid);
service.setWalkable(0, 0, true);
const expectedGrid = [
[0, 0],
[0, 0],
];
expect(mockEasyStarInstance.setGrid).toHaveBeenCalledWith(expectedGrid);
});
test("no-ops on out-of-bounds coordinates", () => {
const grid = [
[0, 0],
[0, 0],
];
service.setGrid(grid);
// Reset mock call count after setGrid
mockEasyStarInstance.setGrid.mockClear();
service.setWalkable(-1, 0, false);
service.setWalkable(0, 99, false);
service.setWalkable(5, 0, false);
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test("no-ops when value unchanged", () => {
const grid = [
[0, 0],
[0, 0],
];
service.setGrid(grid);
mockEasyStarInstance.setGrid.mockClear();
service.setWalkable(0, 0, true); // already 0
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
});
// ── findPath ────────────────────────────────────────────────────
describe("findPath", () => {
test("resolves with path when EasyStar finds one", async () => {
const grid = gridWithWall(5, 5, 2, 0, 5); // full-height wall at x=2
service.setGrid(grid);
const expectedPath = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 2, y: 2 }, // actually blocked in our grid — but EasyStar mock returns whatever we pass
];
// Make findPath invoke the callback with the path
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(expectedPath);
},
);
const result = await service.findPath({ x: 0, y: 0 }, { x: 4, y: 0 });
expect(result).toEqual(expectedPath);
expect(mockEasyStarInstance.findPath).toHaveBeenCalledWith(
0, 0, 4, 0, expect.any(Function),
);
expect(mockEasyStarInstance.calculate).toHaveBeenCalled();
});
test("resolves with null when no path exists", async () => {
const grid = gridWithWall(5, 5, 2, 0, 5); // full-height wall
service.setGrid(grid);
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(null);
},
);
const result = await service.findPath({ x: 0, y: 0 }, { x: 4, y: 0 });
expect(result).toBeNull();
});
});
// ── isValidMove ─────────────────────────────────────────────────
describe("isValidMove", () => {
test("returns true when a path exists between adjacent tiles", async () => {
const grid = [
[0, 0, 0],
[0, 0, 0],
[0, 0, 0],
];
service.setGrid(grid);
const validPath = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
];
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(validPath);
},
);
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 1, y: 0 });
expect(result).toBe(true);
});
test("returns false when no path exists", async () => {
// Two tiles separated by a wall
const grid = [
[0, 1, 0],
[0, 1, 0],
[0, 0, 0],
];
service.setGrid(grid);
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(null);
},
);
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 2, y: 0 });
expect(result).toBe(false);
});
test("returns false when to tile is blocked", async () => {
const grid = [
[0, 0],
[0, 1], // (1,1) is blocked
];
service.setGrid(grid);
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(null);
},
);
const result = await service.isValidMove({ x: 0, y: 0 }, { x: 1, y: 1 });
expect(result).toBe(false);
});
});
// ── Straight-line path ──────────────────────────────────────────
describe("straight-line path", () => {
test("returns direct horizontal path on clear grid", async () => {
const grid = [
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
];
service.setGrid(grid);
const straightPath = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 },
];
mockFindPath.mockImplementationOnce(
(sx: number, sy: number, ex: number, ey: number, cb: Function) => {
cb(straightPath);
},
);
const result = await service.findPath({ x: 0, y: 0 }, { x: 3, y: 0 });
expect(result).toEqual(straightPath);
expect(result?.length).toBe(4);
// All tiles should be on the same row
expect(result?.every((t: { x: number; y: number }) => t.y === 0)).toBe(true);
});
});
});