1import { describe, expect, it } from "vitest";
2import {
3 anchorPoint,
4 arrowHead,
5 connectorPath,
6 decimatePoints,
7 elementBounds,
8 hitTest,
9 nearestAnchor,
10 pointInRect,
11 displayBounds,
12 rectsIntersect,
13 screenToWorld,
14 unionAll,
15 unionRect,
16 viewportToWorldRect,
17 worldToScreen,
18 zoomAt,
19 type Viewport
20} from "./geometry.js";
21import type { CanvasElement } from "./schema.js";
22
23const rect = (over: Partial<CanvasElement> = {}): CanvasElement =>
24 ({ id: "r1", type: "rect", x: 0, y: 0, w: 100, h: 50, z: 1, fill: null, stroke: null, ...over }) as CanvasElement;
25
26describe("geometry (T-167)", () => {
27 it("union of rects", () => {
28 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 });
29 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 });
30 expect(unionAll([])).toEqual({ x: 0, y: 0, w: 0, h: 0 });
31 });
32
33 it("point-in-rect is edge-inclusive", () => {
34 const r = { x: 0, y: 0, w: 10, h: 10 };
35 expect(pointInRect(0, 0, r)).toBe(true);
36 expect(pointInRect(10, 10, r)).toBe(true);
37 expect(pointInRect(10.0001, 5, r)).toBe(false);
38 });
39
40 it("hitTest shapes = bounds; freehand = stroke proximity", () => {
41 const el = rect();
42 expect(hitTest(el, 50, 25)).toBe(true);
43 expect(hitTest(el, 101, 25)).toBe(false);
44
45 const stroke: CanvasElement = {
46 id: "f1", type: "freehand", x: 0, y: 0, w: 100, h: 0, z: 1,
47 points: [0, 0, 100, 0], strokeWidth: 4
48 } as CanvasElement;
49 expect(hitTest(stroke, 50, 0)).toBe(true);
50 expect(hitTest(stroke, 50, 3)).toBe(true);
51 expect(hitTest(stroke, 50, 20)).toBe(false);
52 });
53
54 it("anchorPoint returns the 4 edge midpoints", () => {
55 const el = rect();
56 expect(anchorPoint(el, "top")).toEqual({ x: 50, y: 0 });
57 expect(anchorPoint(el, "bottom")).toEqual({ x: 50, y: 50 });
58 expect(anchorPoint(el, "left")).toEqual({ x: 0, y: 25 });
59 expect(anchorPoint(el, "right")).toEqual({ x: 100, y: 25 });
60 });
61
62 it("connectorPath straight + orthogonal (dominant-axis routing)", () => {
63 expect(connectorPath({ x: 0, y: 0 }, { x: 100, y: 50 }, "straight")).toBe("M 0 0 L 100 50");
64
65 const o = connectorPath({ x: 0, y: 0 }, { x: 100, y: 50 }, "orthogonal");
66 expect(o).toBe("M 0 0 L 50 0 L 50 50 L 100 50");
67
68 const v = connectorPath({ x: 0, y: 0 }, { x: 20, y: 100 }, "orthogonal");
69 expect(v).toBe("M 0 0 L 0 50 L 20 50 L 20 100");
70 });
71
72 it("arrowHead points along the direction, base behind the tip", () => {
73 const tip = { x: 100, y: 0 };
74 const dir = { x: 1, y: 0 };
75 const pts = arrowHead(tip, dir, 10);
76 expect(pts[0]).toEqual([100, 0]);
77 const [tipP, a, b] = pts;
78 expect(a![0]).toBeLessThan(100);
79 expect(b![0]).toBeLessThan(100);
80
81 expect(Math.abs(a![1])).toBe(5);
82 expect(a![1] * b![1]).toBeLessThan(0);
83 void tipP;
84 });
85
86 it("decimatePoints keeps endpoints, collapses near-collinear, respects tolerance", () => {
87
88 const pts: number[] = [];
89 for (let i = 0; i <= 100; i++) pts.push(i, i + (i % 7 === 0 ? 0 : 0.1));
90 const out = decimatePoints(pts, 0.5);
91 expect(out.slice(0, 2)).toEqual(pts.slice(0, 2));
92 expect(out.slice(-2)).toEqual(pts.slice(-2));
93 expect(out.length).toBeLessThan(pts.length);
94
95
96 const square: number[] = [0, 0, 100, 0, 100, 100, 0, 100, 0, 0];
97 const s = decimatePoints(square, 0.1);
98 expect(s).toEqual(square);
99
100
101 expect(decimatePoints([1, 2, 3, 4], 0.1)).toEqual([1, 2, 3, 4]);
102 expect(decimatePoints([1, 2], 0.1)).toEqual([1, 2]);
103 });
104
105 it("world<->screen mapping is invertible; zoomAt keeps the anchor fixed", () => {
106 const vp: Viewport = { scale: 2, tx: 10, ty: -5 };
107 const s = worldToScreen(vp, 10, 20);
108 expect(s).toEqual({ x: 30, y: 35 });
109 expect(screenToWorld(vp, s.x, s.y)).toEqual({ x: 10, y: 20 });
110
111 const zoomed = zoomAt(vp, 30, 35, 2);
112 expect(zoomed.scale).toBe(4);
113 expect(worldToScreen(zoomed, 10, 20)).toEqual({ x: 30, y: 35 });
114
115
116 expect(zoomAt(vp, 0, 0, 1e6).scale).toBe(8);
117 expect(zoomAt(vp, 0, 0, 1e-9).scale).toBe(0.05);
118 });
119
120 it("elementBounds delegates to the element geometry", () => {
121 expect(elementBounds(rect({ x: 5, y: 6, w: 7, h: 8 }))).toEqual({ x: 5, y: 6, w: 7, h: 8 });
122 });
123});
124
125describe("viewport culling (2,000-object perf gate)", () => {
126 const vp = (scale: number, tx: number, ty: number): Viewport => ({ scale, tx, ty });
127
128 it("viewportToWorldRect maps the screen box to a world rect (scale + pan)", () => {
129
130 expect(viewportToWorldRect(vp(1, 0, 0), 800, 600)).toEqual({ x: 0, y: 0, w: 800, h: 600 });
131
132 const r = viewportToWorldRect(vp(2, 64, 32), 800, 600);
133 expect(r).toEqual({ x: -32, y: -16, w: 400, h: 300 });
134
135
136 const neg = viewportToWorldRect(vp(1, 500, 400), 100, 100);
137 expect(neg).toEqual({ x: -500, y: -400, w: 100, h: 100 });
138 });
139
140 it("viewportToWorldRect returns null for a degenerate/unknown box (no layout → render all)", () => {
141 expect(viewportToWorldRect(vp(1, 0, 0), 0, 600)).toBeNull();
142 expect(viewportToWorldRect(vp(1, 0, 0), 800, 0)).toBeNull();
143 expect(viewportToWorldRect(vp(1, 0, 0), -5, 600)).toBeNull();
144 });
145
146 it("rectsIntersect is edge-exclusive (a touching edge does not intersect)", () => {
147 const a = { x: 0, y: 0, w: 10, h: 10 };
148 expect(rectsIntersect(a, { x: 5, y: 5, w: 10, h: 10 })).toBe(true);
149 expect(rectsIntersect(a, { x: 10, y: 0, w: 5, h: 5 })).toBe(false);
150 expect(rectsIntersect(a, { x: -5, y: -5, w: 4, h: 4 })).toBe(false);
151 expect(rectsIntersect(a, { x: -1, y: -1, w: 2, h: 2 })).toBe(true);
152 });
153
154 it("displayBounds: non-connectors use their own box; connectors use the endpoint union", () => {
155 const a = { id: "a", type: "rect", x: 0, y: 0, w: 100, h: 100, z: 1, fill: null, stroke: null } as CanvasElement;
156 const b = { id: "b", type: "rect", x: 500, y: 300, w: 50, h: 50, z: 2, fill: null, stroke: null } as CanvasElement;
157 const elements = { a, b };
158
159 expect(displayBounds(a, elements)).toEqual({ x: 0, y: 0, w: 100, h: 100 });
160
161 const conn = {
162 id: "c",
163 type: "connector",
164 x: 0, y: 0, w: 0, h: 0, z: 3,
165 from: { elementId: "a", anchor: "right" },
166 to: { elementId: "b", anchor: "left" },
167 kind: "straight", arrow: true
168 } as CanvasElement;
169 expect(displayBounds(conn, elements)).toEqual({ x: 0, y: 0, w: 550, h: 350 });
170
171 const connOrphan = { ...conn, from: { elementId: "nope", anchor: "right" } } as CanvasElement;
172 expect(displayBounds(connOrphan, elements)).toEqual({ x: 0, y: 0, w: 0, h: 0 });
173 });
174});
175
176describe("connector routing (T-169 exit criteria)", () => {
177 it("orthogonal routing follows the dominant axis with exactly 3 segments, both directions", () => {
178
179 const h = connectorPath({ x: 0, y: 0 }, { x: 200, y: 50 }, "orthogonal");
180 expect(h).toBe("M 0 0 L 100 0 L 100 50 L 200 50");
181
182 const v = connectorPath({ x: 0, y: 0 }, { x: 50, y: 200 }, "orthogonal");
183 expect(v).toBe("M 0 0 L 0 100 L 50 100 L 50 200");
184
185 expect(h.split(" L ").length).toBe(4);
186 expect(v.split(" L ").length).toBe(4);
187
188 expect(connectorPath({ x: 0, y: 0 }, { x: 5, y: 200 }, "straight")).toBe("M 0 0 L 5 200");
189 });
190
191 it("nearestAnchor picks the closest of the 4 edge midpoints (connector tool attach point)", () => {
192 const el = rect();
193 expect(nearestAnchor(el, { x: 90, y: 25 })).toBe("right");
194 expect(nearestAnchor(el, { x: 60, y: 40 })).toBe("bottom");
195 expect(nearestAnchor(el, { x: 10, y: 30 })).toBe("left");
196 expect(nearestAnchor(el, { x: 45, y: 5 })).toBe("top");
197
198 expect(nearestAnchor(el, { x: 50, y: 25 })).toBe("top");
199 });
200});
201