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