velxio/frontend/src/__tests__/wire-auto-route.test.ts

305 lines
13 KiB
TypeScript

/**
* First-time wire auto-routing around component bounding boxes.
*/
import { describe, it, expect } from 'vitest';
import {
routeAroundObstacles,
collectWireSegments,
ROUTE_MARGIN,
WIRE_SEPARATION,
type ObstacleRect,
} from '../utils/wireAutoRoute';
import { expandOrthogonalPoints } from '../utils/wireUtils';
/** True when no segment of [start, ...corners, end] crosses an inflated rect. */
function routeAvoids(
start: { x: number; y: number },
corners: { x: number; y: number }[],
end: { x: number; y: number },
rects: ObstacleRect[],
): boolean {
const pts = expandOrthogonalPoints([start, ...corners, end]);
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
for (const r of rects) {
const rx = r.x - ROUTE_MARGIN;
const ry = r.y - ROUTE_MARGIN;
const rw = r.w + 2 * ROUTE_MARGIN;
const rh = r.h + 2 * ROUTE_MARGIN;
if (a.y === b.y) {
if (a.y > ry && a.y < ry + rh && Math.max(a.x, b.x) > rx && Math.min(a.x, b.x) < rx + rw)
return false;
} else if (a.x > rx && a.x < rx + rw && Math.max(a.y, b.y) > ry && Math.min(a.y, b.y) < ry + rh) {
return false;
}
}
}
return true;
}
describe('routeAroundObstacles', () => {
const start = { x: 0, y: 0 };
const end = { x: 300, y: 200 };
it('returns null with no obstacles (default elbow keeps working)', () => {
expect(routeAroundObstacles(start, end, [])).toBeNull();
});
it('returns null when the preview elbow is already clear', () => {
// Obstacle far away from both L-routes
expect(
routeAroundObstacles(start, end, [{ x: 1000, y: 1000, w: 50, h: 50 }]),
).toBeNull();
});
it('uses the other elbow orientation when only the preview one is blocked', () => {
// dy (200) > dx... no: dx=300 >= dy=200 → preview goes horizontal-first
// through (300, 0). Block that with a rect on the top edge.
const rects: ObstacleRect[] = [{ x: 120, y: -30, w: 60, h: 60 }];
const corners = routeAroundObstacles(start, end, rects);
expect(corners).toEqual([{ x: 0, y: 200 }]);
expect(routeAvoids(start, corners!, end, rects)).toBe(true);
});
it('routes around an obstacle blocking both L orientations', () => {
// A tall block straddling the middle blocks horizontal-first and
// vertical-first alike; A* must detour around it.
const rects: ObstacleRect[] = [{ x: 100, y: -100, w: 60, h: 400 }];
const corners = routeAroundObstacles(start, end, rects);
expect(corners).not.toBeNull();
expect(corners!.length).toBeGreaterThan(0);
expect(routeAvoids(start, corners!, end, rects)).toBe(true);
});
it('an endpoint-containing rect gets an escape corridor, not a free pass', () => {
// The obstacle sits right on the start pin. The wire must be able to
// LEAVE (routing can never fail because of the pin's own body), but the
// rest of the body must still repel the route — dropping the rect
// entirely let wires cross seated displays end to end.
const r = routeAroundObstacles(start, end, [{ x: -20, y: -20, w: 40, h: 40 }]);
// A route (or null when the escape happens to align with the direct
// elbow) — either way it must not throw and must produce a usable shape.
expect(r === null || Array.isArray(r)).toBe(true);
});
it('falls back to null when the target is fully walled off', () => {
// Four rects boxing the end point with no gap
const rects: ObstacleRect[] = [
{ x: 200, y: 100, w: 200, h: 20 },
{ x: 200, y: 280, w: 200, h: 20 },
{ x: 200, y: 100, w: 20, h: 200 },
{ x: 380, y: 100, w: 20, h: 200 },
];
expect(routeAroundObstacles(start, { x: 300, y: 200 }, rects)).toBeNull();
});
it('keeps the route orthogonal', () => {
const rects: ObstacleRect[] = [{ x: 100, y: -100, w: 60, h: 400 }];
const corners = routeAroundObstacles(start, end, rects)!;
const pts = [start, ...corners, end];
for (let i = 1; i < pts.length; i++) {
expect(pts[i - 1].x === pts[i].x || pts[i - 1].y === pts[i].y).toBe(true);
}
});
});
describe('routeAroundObstacles — wire avoidance (soft costs)', () => {
const seg = (x1: number, y1: number, x2: number, y2: number) => ({
a: { x: x1, y: y1 },
b: { x: x2, y: y2 },
});
/** Worst parallel-overlap px of the full route against the segments. */
const parallelOverlap = (
start: { x: number; y: number },
corners: { x: number; y: number }[],
end: { x: number; y: number },
segs: Array<{ a: { x: number; y: number }; b: { x: number; y: number } }>,
) => {
const pts = expandOrthogonalPoints([start, ...corners, end]);
let total = 0;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
const horizontal = a.y === b.y;
for (const s of segs) {
if ((s.a.y === s.b.y) !== horizontal) continue;
const gap = horizontal ? Math.abs(a.y - s.a.y) : Math.abs(a.x - s.a.x);
if (gap >= WIRE_SEPARATION) continue;
const lo = horizontal
? Math.max(Math.min(a.x, b.x), Math.min(s.a.x, s.b.x))
: Math.max(Math.min(a.y, b.y), Math.min(s.a.y, s.b.y));
const hi = horizontal
? Math.min(Math.max(a.x, b.x), Math.max(s.a.x, s.b.x))
: Math.min(Math.max(a.y, b.y), Math.max(s.a.y, s.b.y));
total += Math.max(0, hi - lo);
}
}
return total;
};
it('null when no obstacles and no wires', () => {
expect(routeAroundObstacles({ x: 0, y: 0 }, { x: 300, y: 0 }, [], [])).toBeNull();
});
it('dodges sideways instead of riding along a collinear wire', () => {
// Existing wire runs exactly along the direct path start->end, but
// does NOT terminate on either pin (a wire ending on our own pin is
// exempt — it must touch there).
const start = { x: 0, y: 100 };
const end = { x: 300, y: 100 };
const existing = [seg(-50, 100, 350, 100)];
const corners = routeAroundObstacles(start, end, [], existing);
expect(corners).not.toBeNull();
// The routed shape must NOT overlap the existing wire in parallel
// (endpoints touching at the shared start/end region are exempt zones,
// but this wire shares no endpoint: it is exactly under the route).
expect(parallelOverlap(start, corners!, end, existing)).toBe(0);
});
it('crossing a perpendicular wire stays allowed (no absurd detours)', () => {
// A vertical wire fully spanning the corridor: crossing is unavoidable.
const start = { x: 0, y: 100 };
const end = { x: 300, y: 100 };
const existing = [seg(150, -10000, 150, 10000)];
// Route may be null (direct considered fine) or a route that still
// crosses; either way it must not fail or detour to infinity.
const corners = routeAroundObstacles(start, end, [], existing);
if (corners) {
// All corners stay within a sane window around the route.
for (const c of corners!) {
expect(Math.abs(c.y - 100)).toBeLessThan(200);
}
}
});
it('a wire sharing an endpoint with the route is exempt', () => {
// Another wire starts at the same pin — near the pin they MUST touch.
const start = { x: 0, y: 100 };
const end = { x: 300, y: 100 };
const sharing = [seg(0, 100, 0, 300)]; // starts exactly at `start`
expect(routeAroundObstacles(start, end, [], sharing)).toBeNull();
});
it('routes a second parallel wire as a side-by-side lane (bus look)', () => {
// First wire occupies y=100; the second, 4px away vertically at both
// pins, should shift to a clear lane rather than ride on the first.
const start = { x: 0, y: 104 };
const end = { x: 300, y: 104 };
const existing = [seg(-50, 100, 350, 100)];
const corners = routeAroundObstacles(start, end, [], existing);
expect(corners).not.toBeNull();
expect(parallelOverlap(start, corners!, end, existing)).toBe(0);
});
it('wires outside the window are ignored (grid stays small)', () => {
// 200 far-away wires must not blow the coordinate cap or the route.
const start = { x: 0, y: 0 };
const end = { x: 300, y: 0 };
const far = Array.from({ length: 200 }, (_, i) => seg(5000 + i * 10, 0, 5000 + i * 10, 500));
expect(routeAroundObstacles(start, end, [], far)).toBeNull();
});
it('degrades to null rather than failing when boxed in', () => {
const start = { x: 0, y: 0 };
const end = { x: 10, y: 0 };
const cage: ObstacleRect[] = [
{ x: -50, y: -50, w: 120, h: 20 },
{ x: -50, y: 30, w: 120, h: 20 },
];
// Whatever happens, it returns corners or null — never throws.
const r = routeAroundObstacles(start, end, cage, []);
expect(r === null || Array.isArray(r)).toBe(true);
});
});
describe('collectWireSegments', () => {
it('skips bb wires and the wire being routed', () => {
const wires = [
{ id: 'w1', start: { x: 0, y: 0 }, end: { x: 100, y: 0 }, waypoints: [] },
{ id: 'seat', bb: true, start: { x: 5, y: 5 }, end: { x: 5, y: 5 }, waypoints: [] },
{ id: 'me', start: { x: 0, y: 50 }, end: { x: 100, y: 50 }, waypoints: [] },
];
const segs = collectWireSegments(wires as never, 'me');
expect(segs.length).toBeGreaterThan(0);
// Only w1's horizontal run at y=0 — nothing at y=50 or from the seat.
expect(segs.every((s) => s.a.y === 0 && s.b.y === 0)).toBe(true);
});
it('expands implicit elbows so costs see what the user sees', () => {
const wires = [
{ id: 'w1', start: { x: 0, y: 0 }, end: { x: 100, y: 80 }, waypoints: [] },
];
const segs = collectWireSegments(wires as never);
// Diagonal endpoints render as an L: one horizontal + one vertical.
expect(segs).toHaveLength(2);
});
});
describe('routeAroundObstacles — escape corridors (endpoint inside obstacle)', () => {
it('a wire leaving a strip under a seated display does not cross its body', () => {
// The reported case: a 4-digit display body ~200x95 seated on the
// breadboard; the wire starts at a strip hole INSIDE the inflated bbox
// (right under the display pins) and ends far to the right. Dropping
// the rect let 15 wires run straight across the display.
const display: ObstacleRect = { x: 523, y: 86, w: 210, h: 103 };
const start = { x: 540, y: 180 }; // inside, near the BOTTOM edge
const end = { x: 900, y: 120 }; // to the right, level with the body
const corners = routeAroundObstacles(start, end, [display], []);
expect(corners).not.toBeNull();
// No horizontal run may pass through the body interior above the
// escape row (i.e. the route must go around, not across).
const pts = expandOrthogonalPoints([start, ...corners!, end]);
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
if (a.y === b.y && a.y > 86 && a.y < 172) {
// horizontal inside the body's vertical span (above the corridor
// mouth region) must not overlap the body's x-range interior
const overl = Math.min(Math.max(a.x, b.x), 523 + 210) - Math.max(Math.min(a.x, b.x), 523);
expect(overl, `run at y=${a.y} crosses the display`).toBeLessThanOrEqual(16);
}
}
});
it('still routes when BOTH endpoints sit inside the same rect', () => {
// Strip-to-strip wire fully under the display: carving twice may leave
// nothing blocked — must not throw, must return something sane.
const display: ObstacleRect = { x: 0, y: 0, w: 300, h: 100 };
const r = routeAroundObstacles({ x: 30, y: 90 }, { x: 250, y: 90 }, [display], []);
expect(r === null || Array.isArray(r)).toBe(true);
});
it('endpoint-outside rects behave exactly as before', () => {
const rect: ObstacleRect = { x: 100, y: -50, w: 50, h: 100 };
const corners = routeAroundObstacles({ x: 0, y: 0 }, { x: 300, y: 0 }, [rect], []);
expect(corners).not.toBeNull(); // must detour around it
});
});
describe('routeAroundObstacles — overlapping obstacle slab (seated resistor bank)', () => {
it('escapes a point buried in overlapping rects via one shared corridor', () => {
// 8 resistors at 19px pitch with ~66px inflated boxes form a solid slab.
// With per-rect escape directions the corridors contradicted each other
// and A* found no exit — the wire fell back to a display-crossing elbow.
const slab: ObstacleRect[] = Array.from({ length: 8 }, (_, i) => ({
x: 850 + i * 19, y: 440, w: 50, h: 105,
}));
const display: ObstacleRect = { x: 500, y: 434, w: 202, h: 95 };
const start = { x: 906, y: 521 }; // strip hole inside 2-3 resistors
const end = { x: 100, y: 219 }; // GPIO far left, above
const corners = routeAroundObstacles(start, end, [display, ...slab], []);
expect(corners).not.toBeNull();
// The route must not cross the display body.
const pts = expandOrthogonalPoints([start, ...corners!, end]);
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1]; const b = pts[i];
const inX = Math.max(a.x, b.x) > 500 + 4 && Math.min(a.x, b.x) < 702 - 4;
const inY = Math.max(a.y, b.y) > 434 + 4 && Math.min(a.y, b.y) < 529 - 4;
expect(inX && inY, `segment (${a.x},${a.y})->(${b.x},${b.y}) crosses the display`).toBe(false);
}
});
});