Files
claw3d/tests/unit/navigation.astarFallback.test.ts
T
iamlukethedev fcecece1c3 fix(test): correct same-cell astar assertion to match code behavior
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
2026-03-27 13:19:47 -05:00

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
});
});