Files
claw3d/tests/unit/navigation.navBlockers.test.ts
T
robotica4us-collab 464a49bb6d fix(navigation): block desk_cubicle in nav grid with zero padding to prevent walk-through (#75)
- desk_cubicle now has blocksNavigation: true with navPadding: 0
  (tight blocking, no inflation — aisles stay clear)
- buildNavGrid reads per-item navPadding from ITEM_METADATA
- getDeskLocations targets y-5 (chair position, above desk blocked zone)
- Agents route AROUND desks instead of through them
- Chair stays passable so agents can reach their sitting position

Fixes object passthrough for desks. Other large passable items
(doors, lamps) unchanged — they remain non-blocking by design.

Co-authored-by: Neo (subagent) <neo@openclaw.local>
Co-authored-by: Luke The Dev <252071647+iamlukethedev@users.noreply.github.com>
2026-03-28 22:08:05 -05:00

234 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { CANVAS_H, CANVAS_W } from "@/features/retro-office/core/constants";
import { astar, buildNavGrid } from "@/features/retro-office/core/navigation";
import { ITEM_METADATA } from "@/features/retro-office/core/geometry";
import type { FurnitureItem } from "@/features/retro-office/core/types";
// Minimal helper: creates a FurnitureItem at a given position.
const makeItem = (type: string, x = 100, y = 100): FurnitureItem => ({
_uid: `test_${type}`,
type,
x,
y,
});
/**
* Returns true if ANY cell in the grid that overlaps the given world-space
* rectangle is marked as blocked (value === 1).
*/
const isBlocked = (
grid: Uint8Array,
wx: number,
wy: number,
ww = 30,
wh = 30,
): boolean => {
const GRID_CELL = 25;
const GRID_COLS = Math.ceil(CANVAS_W / GRID_CELL);
const GRID_ROWS = Math.ceil(CANVAS_H / GRID_CELL);
const c1 = Math.max(0, Math.floor(wx / GRID_CELL));
const c2 = Math.min(GRID_COLS - 1, Math.floor((wx + ww) / GRID_CELL));
const r1 = Math.max(0, Math.floor(wy / GRID_CELL));
const r2 = Math.min(GRID_ROWS - 1, Math.floor((wy + wh) / GRID_CELL));
for (let r = r1; r <= r2; r++) {
for (let c = c1; c <= c2; c++) {
if (grid[r * GRID_COLS + c] === 1) return true;
}
}
return false;
};
describe("buildNavGrid solid floor props block pathfinding (issue #4)", () => {
// The five types that were previously missing from the blocking set (issue #4).
const solidProps = [
"water_cooler",
"server_terminal",
"dishwasher",
"easel",
"beanbag",
] as const;
for (const propType of solidProps) {
it(`marks cells occupied by '${propType}' as blocked`, () => {
const item = makeItem(propType, 400, 300);
const grid = buildNavGrid([item]);
// The item is placed at (400, 300); any cell in that vicinity should be blocked.
expect(isBlocked(grid, 400, 300)).toBe(true);
});
}
it("does not block cells occupied by non-solid props (e.g. keyboard)", () => {
// A keyboard is a desk decoration and should NOT block walking paths.
const item = makeItem("keyboard", 400, 300);
const grid = buildNavGrid([item]);
// Centre cell of the item should remain free (border cells are always blocked, pick interior).
expect(isBlocked(grid, 400, 300)).toBe(false);
});
});
describe("buildNavGrid metadata-driven blocking (issue #4 rework)", () => {
it("respects ITEM_METADATA.blocksNavigation for known blocking types", () => {
// Spot-check a few well-known blocking types derived from metadata.
const blockingTypes = Object.entries(ITEM_METADATA)
.filter(([, meta]) => meta.blocksNavigation)
.map(([type]) => type);
for (const type of blockingTypes) {
const item = makeItem(type, 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300), `expected '${type}' to block`).toBe(true);
}
});
it("does not block for known non-blocking types from ITEM_METADATA", () => {
const nonBlockingTypes = Object.entries(ITEM_METADATA)
.filter(([, meta]) => !meta.blocksNavigation)
.map(([type]) => type);
for (const type of nonBlockingTypes) {
const item = makeItem(type, 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300), `expected '${type}' NOT to block`).toBe(false);
}
});
it("a new item type with blocksNavigation: true is correctly blocked by buildNavGrid", () => {
// Simulate adding a brand-new prop type to ITEM_METADATA at runtime.
// This verifies the metadata-driven path works end-to-end for future additions.
const testType = "__test_new_blocking_prop__";
ITEM_METADATA[testType] = { blocksNavigation: true };
try {
const item = makeItem(testType, 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300)).toBe(true);
} finally {
// Clean up the temporary entry so it doesn't affect other tests.
delete ITEM_METADATA[testType];
}
});
it("an unknown item type defaults to non-blocking (safe fallback)", () => {
// Types not listed in ITEM_METADATA should never accidentally block navigation.
const item = makeItem("__completely_unknown_type__", 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Additional edge-case and integration tests (issue #4)
// ---------------------------------------------------------------------------
describe("buildNavGrid continuous wall from adjacent blocking items (issue #4)", () => {
it("adjacent blocking items form a continuous impassable wall", () => {
/*
* Place three `vending` machines side-by-side (each 40×60 px) at x=200,220,240.
* Together they should create a solid horizontal band from y=300 that blocks
* the entire horizontal span — no path can slip between them.
*
* We verify by checking that cells at several x positions along the row are
* all blocked.
*/
const items: FurnitureItem[] = [
makeItem("vending", 200, 300),
makeItem("vending", 240, 300),
makeItem("vending", 280, 300),
];
const grid = buildNavGrid(items);
// All three placement regions should be blocked.
expect(isBlocked(grid, 200, 300)).toBe(true);
expect(isBlocked(grid, 240, 300)).toBe(true);
expect(isBlocked(grid, 280, 300)).toBe(true);
});
});
describe("buildNavGrid near-boundary placement (issue #4)", () => {
it("blocking item near the grid edge does not cause out-of-bounds errors", () => {
// Place a large item near the right/bottom edges of the canvas.
// buildNavGrid clamps cells to valid indices — this must not throw.
const nearEdge = makeItem("cabinet", CANVAS_W - 40, CANVAS_H - 40);
expect(() => buildNavGrid([nearEdge])).not.toThrow();
const grid = buildNavGrid([nearEdge]);
// The grid array length must still be correct.
const GRID_CELL = 25;
const GRID_COLS = Math.ceil(CANVAS_W / GRID_CELL);
const GRID_ROWS = Math.ceil(CANVAS_H / GRID_CELL);
expect(grid.length).toBe(GRID_COLS * GRID_ROWS);
});
it("blocking item at the top-left corner does not cause out-of-bounds errors", () => {
const nearOrigin = makeItem("vending", 0, 0);
expect(() => buildNavGrid([nearOrigin])).not.toThrow();
});
});
describe("buildNavGrid + astar full pathfinding integration (issue #4)", () => {
it("path goes AROUND a blocking item placed between start and end", () => {
/*
* Layout (world coords):
* Start: (100, 350) ← left side
* End: (700, 350) ← right side
* Blocker: cabinet at (400, 300) — 200×40 px, blocks a wide horizontal band
*
* A straight horizontal path would pass through x≈400 y≈300-340. The astar
* path must route around the cabinet, not through it.
*/
const blocker = makeItem("cabinet", 400, 300);
const grid = buildNavGrid([blocker]);
const path = astar(100, 350, 700, 350, grid);
expect(path.length).toBeGreaterThan(0);
// Verify no waypoint falls inside the (padded) blocked region around the cabinet.
// The cabinet is 200×40; buildNavGrid adds 0.6*GRID_CELL ≈ 15 px padding.
const blockedXMin = 400 - 15;
const blockedXMax = 400 + 200 + 15;
const blockedYMin = 300 - 15;
const blockedYMax = 300 + 40 + 15;
for (const { x, y } of path) {
const insideBlocker =
x >= blockedXMin && x <= blockedXMax &&
y >= blockedYMin && y <= blockedYMax;
expect(insideBlocker, `waypoint (${x.toFixed(0)}, ${y.toFixed(0)}) must not be inside the cabinet footprint`).toBe(false);
}
});
});
describe("buildNavGrid specific non-blocking item types (issue #4)", () => {
it("desk_cubicle blocks navigation with zero padding — agents route around desks", () => {
/*
* desk_cubicle has blocksNavigation: true with navPadding: 0 in ITEM_METADATA.
* The desk body is blocked in the nav grid so agents route around it,
* but zero padding keeps aisles between desks navigable.
* Agents sit at the chair position (y - 5), which is above the blocked zone.
*/
const item = makeItem("desk_cubicle", 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300)).toBe(true);
// Confirm via metadata as the authoritative source.
expect(ITEM_METADATA["desk_cubicle"]?.blocksNavigation).toBe(true);
expect(ITEM_METADATA["desk_cubicle"]?.navPadding).toBe(0);
});
it("door does NOT block navigation — agents must be able to walk through doors", () => {
/*
* door has blocksNavigation: false in ITEM_METADATA.
* Doors are structural openings that agents traverse; they must never be
* marked impassable in the nav grid.
*/
const item = makeItem("door", 400, 300);
const grid = buildNavGrid([item]);
expect(isBlocked(grid, 400, 300)).toBe(false);
// Confirm via metadata.
expect(ITEM_METADATA["door"]?.blocksNavigation).toBe(false);
});
});