import { describe, expect, it } from "vitest"; import { anchorPoint, arrowHead, connectorPath, decimatePoints, elementBounds, hitTest, nearestAnchor, pointInRect, displayBounds, rectsIntersect, screenToWorld, unionAll, unionRect, viewportToWorldRect, worldToScreen, zoomAt, type Viewport } from "./geometry.js"; import type { CanvasElement } from "./schema.js"; const rect = (over: Partial = {}): CanvasElement => ({ id: "r1", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null, ...over }) as CanvasElement; describe("geometry (T-167)", () => { it("union of rects", () => { expect(unionRect({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 })).toEqual({ x: 0, y: 0, w: 15, h: 15 }); expect(unionAll([{ x: 1, y: 2, w: 3, h: 4 }, { x: -5, y: 0, w: 4, h: 4 }])).toEqual({ x: -5, y: 0, w: 9, h: 6 }); expect(unionAll([])).toEqual({ x: 0, y: 0, w: 0, h: 0 }); }); it("point-in-rect is edge-inclusive", () => { const r = { x: 0, y: 0, w: 10, h: 10 }; expect(pointInRect(0, 0, r)).toBe(true); expect(pointInRect(10, 10, r)).toBe(true); expect(pointInRect(10.0001, 5, r)).toBe(false); }); it("hitTest shapes = bounds; freehand = stroke proximity", () => { const el = rect(); expect(hitTest(el, 50, 25)).toBe(true); expect(hitTest(el, 101, 25)).toBe(false); const stroke: CanvasElement = { id: "f1", type: "freehand", x: 0, y: 0, w: 100, h: 0, z: 1, points: [0, 0, 100, 0], strokeWidth: 4 } as CanvasElement; expect(hitTest(stroke, 50, 0)).toBe(true); expect(hitTest(stroke, 50, 3)).toBe(true); // within strokeWidth/2 + tolerance expect(hitTest(stroke, 50, 20)).toBe(false); }); it("anchorPoint returns the 4 edge midpoints", () => { const el = rect(); // x0 y0 w100 h50 expect(anchorPoint(el, "top")).toEqual({ x: 50, y: 0 }); expect(anchorPoint(el, "bottom")).toEqual({ x: 50, y: 50 }); expect(anchorPoint(el, "left")).toEqual({ x: 0, y: 25 }); expect(anchorPoint(el, "right")).toEqual({ x: 100, y: 25 }); }); it("connectorPath straight + orthogonal (dominant-axis routing)", () => { expect(connectorPath({ x: 0, y: 0 }, { x: 100, y: 50 }, "straight")).toBe("M 0 0 L 100 50"); // |dx| >= |dy| → horizontal-first const o = connectorPath({ x: 0, y: 0 }, { x: 100, y: 50 }, "orthogonal"); expect(o).toBe("M 0 0 L 50 0 L 50 50 L 100 50"); // |dy| > |dx| → vertical-first const v = connectorPath({ x: 0, y: 0 }, { x: 20, y: 100 }, "orthogonal"); expect(v).toBe("M 0 0 L 0 50 L 20 50 L 20 100"); }); it("arrowHead points along the direction, base behind the tip", () => { const tip = { x: 100, y: 0 }; const dir = { x: 1, y: 0 }; // arriving left→right const pts = arrowHead(tip, dir, 10); expect(pts[0]).toEqual([100, 0]); const [tipP, a, b] = pts; expect(a![0]).toBeLessThan(100); expect(b![0]).toBeLessThan(100); // perpendicular spread (order follows the [tip, +perp, -perp] convention) expect(Math.abs(a![1])).toBe(5); expect(a![1] * b![1]).toBeLessThan(0); // opposite sides void tipP; }); it("decimatePoints keeps endpoints, collapses near-collinear, respects tolerance", () => { // a line with noise: y = x + small const pts: number[] = []; for (let i = 0; i <= 100; i++) pts.push(i, i + (i % 7 === 0 ? 0 : 0.1)); const out = decimatePoints(pts, 0.5); expect(out.slice(0, 2)).toEqual(pts.slice(0, 2)); // start preserved expect(out.slice(-2)).toEqual(pts.slice(-2)); // end preserved expect(out.length).toBeLessThan(pts.length); // a square stroke at tolerance 0.1 keeps all its corners (deviation > tol) const square: number[] = [0, 0, 100, 0, 100, 100, 0, 100, 0, 0]; const s = decimatePoints(square, 0.1); expect(s).toEqual(square); // corners are all retained // degenerate: ≤2 points pass through expect(decimatePoints([1, 2, 3, 4], 0.1)).toEqual([1, 2, 3, 4]); expect(decimatePoints([1, 2], 0.1)).toEqual([1, 2]); }); it("world<->screen mapping is invertible; zoomAt keeps the anchor fixed", () => { const vp: Viewport = { scale: 2, tx: 10, ty: -5 }; const s = worldToScreen(vp, 10, 20); expect(s).toEqual({ x: 30, y: 35 }); expect(screenToWorld(vp, s.x, s.y)).toEqual({ x: 10, y: 20 }); const zoomed = zoomAt(vp, 30, 35, 2); // zoom in on the mapped point expect(zoomed.scale).toBe(4); expect(worldToScreen(zoomed, 10, 20)).toEqual({ x: 30, y: 35 }); // anchor stays put // clamp expect(zoomAt(vp, 0, 0, 1e6).scale).toBe(8); expect(zoomAt(vp, 0, 0, 1e-9).scale).toBe(0.05); }); it("elementBounds delegates to the element geometry", () => { expect(elementBounds(rect({ x: 5, y: 6, w: 7, h: 8 }))).toEqual({ x: 5, y: 6, w: 7, h: 8 }); }); }); describe("viewport culling (2,000-object perf gate)", () => { const vp = (scale: number, tx: number, ty: number): Viewport => ({ scale, tx, ty }); it("viewportToWorldRect maps the screen box to a world rect (scale + pan)", () => { // identity: a 800×600 window at tx=ty=0 → world (0,0)-(800,600) expect(viewportToWorldRect(vp(1, 0, 0), 800, 600)).toEqual({ x: 0, y: 0, w: 800, h: 600 }); // zoom 2 + pan: screen (0,0)→world (-32,-16), (800,600)→world (368,284) const r = viewportToWorldRect(vp(2, 64, 32), 800, 600); expect(r).toEqual({ x: -32, y: -16, w: 400, h: 300 }); // negative world coords are fine (the canvas is unbounded) — a POSITIVE // pan moves the visible window into negative world space const neg = viewportToWorldRect(vp(1, 500, 400), 100, 100); expect(neg).toEqual({ x: -500, y: -400, w: 100, h: 100 }); }); it("viewportToWorldRect returns null for a degenerate/unknown box (no layout → render all)", () => { expect(viewportToWorldRect(vp(1, 0, 0), 0, 600)).toBeNull(); expect(viewportToWorldRect(vp(1, 0, 0), 800, 0)).toBeNull(); expect(viewportToWorldRect(vp(1, 0, 0), -5, 600)).toBeNull(); }); it("rectsIntersect is edge-exclusive (a touching edge does not intersect)", () => { const a = { x: 0, y: 0, w: 10, h: 10 }; expect(rectsIntersect(a, { x: 5, y: 5, w: 10, h: 10 })).toBe(true); // overlap expect(rectsIntersect(a, { x: 10, y: 0, w: 5, h: 5 })).toBe(false); // touching right edge expect(rectsIntersect(a, { x: -5, y: -5, w: 4, h: 4 })).toBe(false); // disjoint expect(rectsIntersect(a, { x: -1, y: -1, w: 2, h: 2 })).toBe(true); // corner overlap }); it("displayBounds: non-connectors use their own box; connectors use the endpoint union", () => { const a = { id: "a", type: "rect", x: 0, y: 0, w: 100, h: 100, z: 1, fill: null, stroke: null } as CanvasElement; const b = { id: "b", type: "rect", x: 500, y: 300, w: 50, h: 50, z: 2, fill: null, stroke: null } as CanvasElement; const elements = { a, b }; // a plain element → its own bounds expect(displayBounds(a, elements)).toEqual({ x: 0, y: 0, w: 100, h: 100 }); // a connector with a 0-sized path box → the union of its endpoints const conn = { id: "c", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 3, from: { elementId: "a", anchor: "right" }, to: { elementId: "b", anchor: "left" }, kind: "straight", arrow: true } as CanvasElement; expect(displayBounds(conn, elements)).toEqual({ x: 0, y: 0, w: 550, h: 350 }); // a connector whose endpoints are missing → falls back to its own box const connOrphan = { ...conn, from: { elementId: "nope", anchor: "right" } } as CanvasElement; expect(displayBounds(connOrphan, elements)).toEqual({ x: 0, y: 0, w: 0, h: 0 }); }); }); describe("connector routing (T-169 exit criteria)", () => { it("orthogonal routing follows the dominant axis with exactly 3 segments, both directions", () => { // horizontal-dominant: |dx|=200 > |dy|=50 → H-V-H const h = connectorPath({ x: 0, y: 0 }, { x: 200, y: 50 }, "orthogonal"); expect(h).toBe("M 0 0 L 100 0 L 100 50 L 200 50"); // vertical-dominant: |dy|=200 > |dx|=50 → V-H-V const v = connectorPath({ x: 0, y: 0 }, { x: 50, y: 200 }, "orthogonal"); expect(v).toBe("M 0 0 L 0 100 L 50 100 L 50 200"); // both paths have exactly 3 line segments (M + 3 L) expect(h.split(" L ").length).toBe(4); expect(v.split(" L ").length).toBe(4); // straight is always a single segment, regardless of dominance expect(connectorPath({ x: 0, y: 0 }, { x: 5, y: 200 }, "straight")).toBe("M 0 0 L 5 200"); }); it("nearestAnchor picks the closest of the 4 edge midpoints (connector tool attach point)", () => { const el = rect(); // x0 y0 w100 h50 → midpoints top(50,0) right(100,25) bottom(50,50) left(0,25) expect(nearestAnchor(el, { x: 90, y: 25 })).toBe("right"); expect(nearestAnchor(el, { x: 60, y: 40 })).toBe("bottom"); expect(nearestAnchor(el, { x: 10, y: 30 })).toBe("left"); expect(nearestAnchor(el, { x: 45, y: 5 })).toBe("top"); // tie-breaks are deterministic (fixed ANCHORS order) expect(nearestAnchor(el, { x: 50, y: 25 })).toBe("top"); // exact center: first anchor wins }); });