fcecece1c3
The test expected [] for same-cell targets, but the code correctly
returns [{ x: ex, y: ey }] so the movement layer can make the final
fine-grained adjustment to the exact pixel.
Made-with: Cursor
220 lines
8.0 KiB
TypeScript
220 lines
8.0 KiB
TypeScript
/**
|
|
* Tests for astar() failure-state behaviour (Issue #3).
|
|
*
|
|
* Before the fix, astar() returned [{ x: endX, y: endY }] when no route
|
|
* could be found, which caused the movement layer to walk agents in a
|
|
* straight line through walls. After the fix it returns [] so callers
|
|
* can treat an empty array as "no path found" and keep the agent still.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { astar, buildNavGrid } from "@/features/retro-office/core/navigation";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build a minimal NavGrid (Uint8Array) of the given dimensions with every
|
|
* cell set to the provided fill value (0 = free, 1 = blocked).
|
|
*/
|
|
function makeGrid(
|
|
cols: number,
|
|
rows: number,
|
|
fill: 0 | 1 = 0,
|
|
): Uint8Array {
|
|
return new Uint8Array(cols * rows).fill(fill);
|
|
}
|
|
|
|
/**
|
|
* Mark a single grid cell as blocked (1) or free (0).
|
|
*/
|
|
function setCell(
|
|
grid: Uint8Array,
|
|
cols: number,
|
|
col: number,
|
|
row: number,
|
|
value: 0 | 1,
|
|
) {
|
|
grid[row * cols + col] = value;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The real nav grid dimensions used by astar() internally.
|
|
// CANVAS_W = 1800, CANVAS_H = 720, GRID_CELL = 25
|
|
// GRID_COLS = ceil(1800/25) = 72, GRID_ROWS = ceil(720/25) = 29
|
|
// ---------------------------------------------------------------------------
|
|
const GRID_CELL = 25;
|
|
const GRID_COLS = 72; // Math.ceil(1800 / 25)
|
|
const GRID_ROWS = 29; // Math.ceil(720 / 25)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("astar — failure state returns empty array (Issue #3 fix)", () => {
|
|
// -------------------------------------------------------------------------
|
|
it("returns [] when the destination is fully enclosed and unreachable", () => {
|
|
// Build a grid with a thick wall ring around a pocket of free cells so
|
|
// findFree cannot escape the ring even with its distance-10 search.
|
|
// We use a 12-cell-thick border wall that completely divides the grid.
|
|
const grid = makeGrid(GRID_COLS, GRID_ROWS, 0);
|
|
|
|
// Block the real border cells.
|
|
for (let col = 0; col < GRID_COLS; col++) {
|
|
setCell(grid, GRID_COLS, col, 0, 1);
|
|
setCell(grid, GRID_COLS, col, GRID_ROWS - 1, 1);
|
|
}
|
|
for (let row = 0; row < GRID_ROWS; row++) {
|
|
setCell(grid, GRID_COLS, 0, row, 1);
|
|
setCell(grid, GRID_COLS, GRID_COLS - 1, row, 1);
|
|
}
|
|
|
|
// Create a thick horizontal wall across the middle of the grid that the
|
|
// agent cannot cross, with a pocket of free cells on the far side.
|
|
// Wall spans all columns from row 12 through row 24 (13 rows thick,
|
|
// much greater than findFree's max search radius of 10 cells).
|
|
const wallTop = 12;
|
|
const wallBottom = 24;
|
|
for (let row = wallTop; row <= wallBottom; row++) {
|
|
for (let col = 1; col < GRID_COLS - 1; col++) {
|
|
setCell(grid, GRID_COLS, col, row, 1);
|
|
}
|
|
}
|
|
|
|
// Source: above the wall, clearly free.
|
|
const sx = 36 * GRID_CELL + GRID_CELL / 2; // col 36, row 5
|
|
const sy = 5 * GRID_CELL + GRID_CELL / 2;
|
|
// Destination: below the wall, in the isolated pocket.
|
|
const ex = 36 * GRID_CELL + GRID_CELL / 2; // col 36, row 27
|
|
const ey = 27 * GRID_CELL + GRID_CELL / 2;
|
|
|
|
const path = astar(sx, sy, ex, ey, grid);
|
|
|
|
expect(path).toEqual([]);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("returns [] when both start and end resolve to the same blocked cell", () => {
|
|
// Fill the entire grid with walls so findFree finds nothing.
|
|
const grid = makeGrid(GRID_COLS, GRID_ROWS, 1);
|
|
|
|
const sx = 300;
|
|
const sy = 300;
|
|
const ex = 400;
|
|
const ey = 400;
|
|
|
|
const path = astar(sx, sy, ex, ey, grid);
|
|
|
|
expect(path).toEqual([]);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("returns a non-empty path for a clearly reachable destination (regression)", () => {
|
|
// Use a real nav grid built from an empty furniture list so all interior
|
|
// cells are free. The only blocked cells are the border walls that
|
|
// buildNavGrid always adds.
|
|
const grid = buildNavGrid([]);
|
|
|
|
// Source: near top-left interior.
|
|
const sx = 100;
|
|
const sy = 100;
|
|
// Destination: near bottom-right interior, well away from borders.
|
|
const ex = 1600;
|
|
const ey = 600;
|
|
|
|
const path = astar(sx, sy, ex, ey, grid);
|
|
|
|
expect(path.length).toBeGreaterThan(0);
|
|
// The last waypoint should be the exact destination.
|
|
const last = path[path.length - 1];
|
|
expect(last).toEqual({ x: ex, y: ey });
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("returns a single-step path when start and end are adjacent free cells", () => {
|
|
const grid = buildNavGrid([]);
|
|
|
|
// One GRID_CELL apart — should produce a very short path.
|
|
const sx = 200;
|
|
const sy = 200;
|
|
const ex = sx + GRID_CELL;
|
|
const ey = sy;
|
|
|
|
const path = astar(sx, sy, ex, ey, grid);
|
|
|
|
expect(path.length).toBeGreaterThan(0);
|
|
const last = path[path.length - 1];
|
|
expect(last).toEqual({ x: ex, y: ey });
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("returns a single-waypoint path when start and end snap to the same free cell", () => {
|
|
// When start and end map to the same grid cell the destination is still
|
|
// reachable — astar returns the exact target pixel so the movement layer
|
|
// can make the final fine-grained adjustment.
|
|
const grid = buildNavGrid([]);
|
|
|
|
// Pick pixel coords that both land in grid cell (4, 4).
|
|
const cellOrigin = 4 * GRID_CELL; // 100
|
|
const sx = cellOrigin + 2; // 102 → still cell (4,4)
|
|
const sy = cellOrigin + 2;
|
|
const ex = cellOrigin + 20; // 120 → still cell (4,4) since floor(120/25)=4
|
|
const ey = cellOrigin + 20;
|
|
|
|
const path = astar(sx, sy, ex, ey, grid);
|
|
|
|
// Same cell — return exact target so the agent can settle onto it.
|
|
expect(path).toEqual([{ x: ex, y: ey }]);
|
|
});
|
|
});
|
|
|
|
describe("movement layer handles empty path gracefully (Issue #3 fix)", () => {
|
|
it("agent stays at its current position when path is empty", () => {
|
|
// Simulate the movement-layer logic extracted from RetroOffice3D.tsx:
|
|
//
|
|
// const path = agent.path ?? [];
|
|
// const wpX = path.length > 0 ? path[0].x : agent.x; // fixed line
|
|
// const wpY = path.length > 0 ? path[0].y : agent.y; // fixed line
|
|
// const dx = wpX - agent.x, dy = wpY - agent.y;
|
|
// const dist = Math.hypot(dx, dy);
|
|
// if (dist > speed) { /* move */ } else { /* stay */ }
|
|
//
|
|
// With the fix applied, an empty path means wpX/wpY = agent.x/agent.y,
|
|
// so dist = 0 and the agent does NOT move.
|
|
|
|
const agentX = 300;
|
|
const agentY = 400;
|
|
const agentTargetX = 900; // far away — would cause wall-walking before fix
|
|
const agentTargetY = 600;
|
|
const WALK_SPEED = 2;
|
|
|
|
// Simulate the fixed movement logic.
|
|
const path: { x: number; y: number }[] = []; // astar returned no route
|
|
const wpX = path.length > 0 ? path[0].x : agentX; // stays at agentX
|
|
const wpY = path.length > 0 ? path[0].y : agentY; // stays at agentY
|
|
const dx = wpX - agentX;
|
|
const dy = wpY - agentY;
|
|
const dist = Math.hypot(dx, dy);
|
|
|
|
// Agent should not move.
|
|
const movedX =
|
|
dist > WALK_SPEED ? agentX + (dx / dist) * WALK_SPEED : agentX;
|
|
const movedY =
|
|
dist > WALK_SPEED ? agentY + (dy / dist) * WALK_SPEED : agentY;
|
|
|
|
expect(movedX).toBe(agentX);
|
|
expect(movedY).toBe(agentY);
|
|
|
|
// Sanity-check: with the OLD (broken) fallback to targetX/targetY,
|
|
// the agent WOULD have moved.
|
|
const oldWpX = path.length > 0 ? path[0].x : agentTargetX;
|
|
const oldWpY = path.length > 0 ? path[0].y : agentTargetY;
|
|
const oldDx = oldWpX - agentX;
|
|
const oldDy = oldWpY - agentY;
|
|
const oldDist = Math.hypot(oldDx, oldDy);
|
|
expect(oldDist).toBeGreaterThan(WALK_SPEED); // confirms old code caused movement
|
|
});
|
|
});
|