// fallow-ignore-file code-duplication import { describe, test, expect } from "vitest"; import { extractSnapTargets, buildCompositionSnapTarget, buildGridSnapEdges, resolveSnapAdjustment, resolveResizeSnapAdjustment, resolveEquidistanceGuides, SNAP_THRESHOLD_PX, type SnapTarget, } from "./snapEngine"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function rect(left: number, top: number, width: number, height: number) { return { left, top, width, height }; } function target(id: string, left: number, top: number, width: number, height: number): SnapTarget { return { left, top, right: left + width, bottom: top + height, centerX: left + width / 2, centerY: top + height / 2, id, }; } // --------------------------------------------------------------------------- // extractSnapTargets // --------------------------------------------------------------------------- describe("extractSnapTargets", () => { test("computes right, bottom, centerX, centerY", () => { const [t] = extractSnapTargets([{ rect: rect(10, 20, 100, 50), id: "a" }]); expect(t.left).toBe(10); expect(t.top).toBe(20); expect(t.right).toBe(110); expect(t.bottom).toBe(70); expect(t.centerX).toBe(60); expect(t.centerY).toBe(45); expect(t.id).toBe("a"); }); test("handles multiple rects", () => { const targets = extractSnapTargets([ { rect: rect(0, 0, 10, 10), id: "x" }, { rect: rect(50, 50, 20, 30), id: "y" }, ]); expect(targets).toHaveLength(2); expect(targets[1].right).toBe(70); expect(targets[1].bottom).toBe(80); }); }); // --------------------------------------------------------------------------- // buildCompositionSnapTarget // --------------------------------------------------------------------------- describe("buildCompositionSnapTarget", () => { test("has id 'composition' and correct edges", () => { const t = buildCompositionSnapTarget(rect(0, 0, 1920, 1080)); expect(t.id).toBe("composition"); expect(t.left).toBe(0); expect(t.right).toBe(1920); expect(t.centerX).toBe(960); expect(t.centerY).toBe(540); }); }); // --------------------------------------------------------------------------- // buildGridSnapEdges // --------------------------------------------------------------------------- describe("buildGridSnapEdges", () => { test("generates correct grid lines", () => { const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 100, 1); // At scale=1, step=100: x lines at 100, 200 (not 0 or 300) expect(x.map((e) => e.position)).toEqual([100, 200]); expect(y.map((e) => e.position)).toEqual([100]); expect(x[0].source).toBe("grid"); }); test("applies scale to grid spacing", () => { const { x } = buildGridSnapEdges(rect(0, 0, 600, 100), 100, 2); // step = 200, lines at 200, 400 expect(x.map((e) => e.position)).toEqual([200, 400]); }); test("handles offset composition rect", () => { const { x } = buildGridSnapEdges(rect(50, 0, 300, 100), 100, 1); // Lines at 150, 250 (offset + step, offset + 2*step) expect(x.map((e) => e.position)).toEqual([150, 250]); }); test("returns empty for zero gridSpacing", () => { const { x, y } = buildGridSnapEdges(rect(0, 0, 300, 200), 0, 1); expect(x).toHaveLength(0); expect(y).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // resolveSnapAdjustment — edge alignment // --------------------------------------------------------------------------- describe("resolveSnapAdjustment", () => { const compositionTarget = target("composition", 0, 0, 1000, 800); test("left-to-right alignment: moving left snaps to target right", () => { // Target at x=200, width=100 => right edge at 300 // Moving rect at x=0, width=50. Propose dx=297 => proposed left=297 // Should snap left=300 (delta +3) const t = target("a", 200, 100, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 100, 50, 50), proposedDx: 297, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(300); expect(result.guides.length).toBeGreaterThanOrEqual(1); expect(result.guides[0].axis).toBe("x"); expect(result.guides[0].position).toBe(300); }); test("right-to-left alignment: moving right snaps to target left", () => { // Target at x=200. Moving rect width=50, at x=0. // Proposed dx=146 => proposed right = 196. Target left=200. diff=4 within threshold. const t = target("a", 200, 100, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 100, 50, 50), proposedDx: 146, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); // Proposed right = 196, target left = 200, adjustment = +4 expect(result.dx).toBe(150); }); test("center-to-center alignment on X axis", () => { // Target center at x=250. Moving rect width=100 at x=0 => center at 50. // Propose dx=198 => proposed center=248, target center=250, diff=2 const t = target("a", 200, 100, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 100, 100, 50), proposedDx: 198, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(200); }); test("top-to-bottom alignment", () => { // Target bottom at 200. Moving top proposed at 197. Should snap to 200. const t = target("a", 100, 100, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(100, 0, 50, 50), proposedDx: 0, proposedDy: 197, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dy).toBe(200); }); test("center-to-center alignment on Y axis", () => { // Target centerY = 150. Moving height=100 at y=0 => center=50. // Propose dy=98 => proposed center=148, target center=150, diff=2 const t = target("a", 100, 100, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(100, 0, 100, 100), proposedDx: 0, proposedDy: 98, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dy).toBe(100); }); test("composition center snap", () => { // Composition center at 500, 400. Moving rect 100x100 at 0,0 => center 50,50. // Propose dx=447, dy=347 => proposed center 497,397. Should snap to 500,400. const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 100, 100), proposedDx: 447, proposedDy: 347, targets: [compositionTarget], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(450); expect(result.dy).toBe(350); }); test("no snap when outside threshold", () => { const t = target("a", 200, 200, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 10, proposedDy: 10, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); // Moving rect edges: left=10, center=35, right=60 // Target edges: left=200, center=250, right=300 // All distances > 6 expect(result.dx).toBe(10); expect(result.dy).toBe(10); expect(result.guides).toHaveLength(0); }); test("multiple matching guides at same distance", () => { // Two targets with left edges at 100 — both should produce guides const t1 = target("a", 100, 0, 50, 50); const t2 = target("b", 100, 200, 50, 50); const result = resolveSnapAdjustment({ movingRect: rect(0, 100, 50, 50), proposedDx: 97, proposedDy: 0, targets: [t1, t2], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(100); // Should have a guide at x=100 const xGuides = result.guides.filter((g) => g.axis === "x"); expect(xGuides.length).toBeGreaterThanOrEqual(1); expect(xGuides[0].position).toBe(100); // The guide extent should cover both targets and the moving rect expect(xGuides[0].from).toBe(0); // t1 top expect(xGuides[0].to).toBe(250); // t2 bottom }); test("disabled=true returns passthrough", () => { const t = target("a", 100, 100, 50, 50); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 98, proposedDy: 98, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: true, }); expect(result.dx).toBe(98); expect(result.dy).toBe(98); expect(result.guides).toHaveLength(0); }); test("threshold=0 means no snap", () => { const t = target("a", 100, 100, 50, 50); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 99, proposedDy: 99, targets: [t], threshold: 0, disabled: false, }); expect(result.dx).toBe(99); expect(result.dy).toBe(99); expect(result.guides).toHaveLength(0); }); test("element snap takes priority over grid snap", () => { // Element left edge at 100. Grid line at 97. // Moving rect proposed left at 98 => dist to element=2, dist to grid=1. // Grid is closer but element should win (priority). // Actually the spec says element takes priority when both match within threshold. // Let's set up: element at 103, grid at 97. Moving proposed left=100. // Dist to element=3, dist to grid=3. Element should win. const t = target("a", 103, 100, 50, 50); const gridEdges = { x: [{ position: 97, source: "grid" as const, id: "grid-x-0" }], y: [], }; const result = resolveSnapAdjustment({ movingRect: rect(0, 100, 50, 50), proposedDx: 100, proposedDy: 0, targets: [t], gridEdges, threshold: SNAP_THRESHOLD_PX, disabled: false, }); // Element at 103 wins over grid at 97 (both within threshold, same distance) expect(result.dx).toBe(103); }); test("grid snap used when no element matches", () => { const gridEdges = { x: [{ position: 100, source: "grid" as const, id: "grid-x-0" }], y: [], }; const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 97, proposedDy: 10, targets: [], gridEdges, threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(100); }); test("snaps X and Y independently", () => { const t = target("a", 200, 300, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 100, 100), proposedDx: 198, proposedDy: 500, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); // X should snap (left-to-left, diff=2), Y should not snap (too far) expect(result.dx).toBe(200); expect(result.dy).toBe(500); }); test("works correctly with many targets (80)", () => { const targets: SnapTarget[] = []; for (let i = 0; i < 80; i++) { targets.push(target(`el-${i}`, i * 50, i * 30, 40, 20)); } // Moving rect near target el-40: left=2000, top=1200 const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 40, 20), proposedDx: 1998, proposedDy: 1198, targets, threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(2000); expect(result.dy).toBe(1200); expect(result.guides.length).toBeGreaterThanOrEqual(1); }); test("opposite-direction tie produces no snap (ambiguous midpoint)", () => { const tA = target("a", 100, 100, 10, 10); const tB = target("b", 120, 100, 10, 10); // Moving rect at x=110, width=10 → left=110, right=120 // tA.right=110, distance=0; tB.left=120, distance=0 — both exact, opposite pull const result = resolveSnapAdjustment({ movingRect: rect(110, 100, 10, 10), proposedDx: 0, proposedDy: 0, targets: [tA, tB], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(0); expect(result.dy).toBe(0); }); test("handles subpixel positions from non-100% zoom", () => { const t = target("a", 200.5, 100.3, 100, 100); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 197.8, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); // left edge at 197.8, target left at 200.5, diff=2.7 within threshold expect(result.dx).toBe(200.5); }); }); // --------------------------------------------------------------------------- // resolveResizeSnapAdjustment // --------------------------------------------------------------------------- describe("resolveResizeSnapAdjustment", () => { test("only right edge snaps on X", () => { // Moving rect at (100, 100) size 50x50, right=150. // Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3. const t = target("a", 200, 100, 100, 100); const result = resolveResizeSnapAdjustment({ movingRect: rect(100, 100, 50, 50), proposedDx: 47, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(50); // right edge snaps to 200 }); test("only bottom edge snaps on Y", () => { // Moving rect at (100, 100) size 50x50, bottom=150. // Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3. const t = target("a", 100, 200, 100, 100); const result = resolveResizeSnapAdjustment({ movingRect: rect(100, 100, 50, 50), proposedDx: 0, proposedDy: 47, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dy).toBe(50); // bottom edge snaps to 200 }); test("left edge does NOT snap during resize", () => { // Target right at 150. Moving rect left=100. If drag were active, // left would snap. But during resize, only right edge snaps. // Moving rect at (100, 100) size 200x200, right=300. // Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3. // This SHOULD snap right to 150 (dx = -150). But left stays at 100. const t = target("a", 50, 100, 100, 100); // right=150 const result = resolveResizeSnapAdjustment({ movingRect: rect(100, 100, 200, 200), proposedDx: -153, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); // Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150 expect(result.dx).toBe(-150); }); test("disabled=true returns passthrough for resize", () => { const t = target("a", 200, 200, 100, 100); const result = resolveResizeSnapAdjustment({ movingRect: rect(100, 100, 50, 50), proposedDx: 47, proposedDy: 47, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: true, }); expect(result.dx).toBe(47); expect(result.dy).toBe(47); expect(result.guides).toHaveLength(0); }); test("resize produces guide lines", () => { const t = target("a", 200, 100, 100, 100); const result = resolveResizeSnapAdjustment({ movingRect: rect(100, 100, 50, 50), proposedDx: 47, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.guides.length).toBeGreaterThanOrEqual(1); const xGuide = result.guides.find((g) => g.axis === "x"); expect(xGuide).toBeDefined(); expect(xGuide!.position).toBe(200); }); }); // --------------------------------------------------------------------------- // resolveEquidistanceGuides // --------------------------------------------------------------------------- describe("resolveEquidistanceGuides", () => { test("detects equal horizontal spacing", () => { // Three elements in a row: A(0..40), moving(70..110), B(140..180) // Gap A-moving = 70 - 40 = 30, gap moving-B = 140 - 110 = 30 => equal const targets = [target("a", 0, 0, 40, 40), target("b", 140, 0, 40, 40)]; const guides = resolveEquidistanceGuides({ movingRect: rect(70, 0, 40, 40), targets, threshold: SNAP_THRESHOLD_PX, }); const xGuides = guides.filter((g) => g.axis === "x"); expect(xGuides.length).toBe(2); expect(xGuides[0].size).toBe(30); expect(xGuides[1].size).toBe(30); }); test("detects equal vertical spacing", () => { // A(y=0..40), moving(y=60..100), B(y=120..160) // Gap = 20 each const targets = [target("a", 0, 0, 40, 40), target("b", 0, 120, 40, 40)]; const guides = resolveEquidistanceGuides({ movingRect: rect(0, 60, 40, 40), targets, threshold: SNAP_THRESHOLD_PX, }); const yGuides = guides.filter((g) => g.axis === "y"); expect(yGuides.length).toBe(2); expect(yGuides[0].size).toBe(20); }); test("no equidistance when gaps differ", () => { // A(0..40), moving(80..120), B(200..240) // Gap A-moving = 40, gap moving-B = 80 => not equal const targets = [target("a", 0, 0, 40, 40), target("b", 200, 0, 40, 40)]; const guides = resolveEquidistanceGuides({ movingRect: rect(80, 0, 40, 40), targets, threshold: SNAP_THRESHOLD_PX, }); const xGuides = guides.filter((g) => g.axis === "x"); expect(xGuides.length).toBe(0); }); test("handles tolerance of 1px", () => { // A(0..40), moving(70..110), B(139..179) // Gap A-moving = 30, gap moving-B = 29 => difference = 1 => within tolerance const targets = [target("a", 0, 0, 40, 40), target("b", 139, 0, 40, 40)]; const guides = resolveEquidistanceGuides({ movingRect: rect(70, 0, 40, 40), targets, threshold: SNAP_THRESHOLD_PX, }); const xGuides = guides.filter((g) => g.axis === "x"); expect(xGuides.length).toBe(2); }); test("ignores overlapping elements", () => { // A(0..100), moving(50..150), B(200..300) — A and moving overlap const targets = [target("a", 0, 0, 100, 40), target("b", 200, 0, 100, 40)]; const guides = resolveEquidistanceGuides({ movingRect: rect(50, 0, 100, 40), targets, threshold: SNAP_THRESHOLD_PX, }); const xGuides = guides.filter((g) => g.axis === "x"); // Gap A-moving = 50 - 100 = -50 (overlap), should be skipped expect(xGuides.length).toBe(0); }); test("only reports triplets involving the moving rect", () => { // A(0..40), B(60..100), C(120..160) — all gaps = 20 but none involves moving // Moving rect is far away at (500..540) const targets = [ target("a", 0, 0, 40, 40), target("b", 60, 0, 40, 40), target("c", 120, 0, 40, 40), ]; const guides = resolveEquidistanceGuides({ movingRect: rect(500, 0, 40, 40), targets, threshold: SNAP_THRESHOLD_PX, }); // The A-B-C triplet doesn't involve moving, so no guides from it // Any triplet involving moving would have huge gaps that don't match const xGuides = guides.filter((g) => g.axis === "x"); expect(xGuides.length).toBe(0); }); }); // --------------------------------------------------------------------------- // Edge cases // --------------------------------------------------------------------------- describe("edge cases", () => { test("empty targets returns passthrough", () => { const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 10, proposedDy: 20, targets: [], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(10); expect(result.dy).toBe(20); expect(result.guides).toHaveLength(0); }); test("exact match (zero distance) produces snap", () => { const t = target("a", 100, 100, 50, 50); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 100, proposedDy: 100, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(100); expect(result.dy).toBe(100); expect(result.guides.length).toBeGreaterThanOrEqual(1); }); test("negative proposed delta works", () => { const t = target("a", 50, 50, 100, 100); // Moving rect at (200, 200), propose dx=-148 => proposed left=52, target left=50, diff=2 const result = resolveSnapAdjustment({ movingRect: rect(200, 200, 50, 50), proposedDx: -148, proposedDy: -148, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(-150); expect(result.dy).toBe(-150); }); test("left-to-left alignment", () => { const t = target("a", 100, 0, 200, 200); const result = resolveSnapAdjustment({ movingRect: rect(0, 300, 80, 80), proposedDx: 97, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(100); }); test("right-to-right alignment", () => { // Target right = 300. Moving rect width=80 at x=0, right=80. // Propose dx=217 => proposed right=297, target right=300, diff=3. const t = target("a", 100, 0, 200, 200); const result = resolveSnapAdjustment({ movingRect: rect(0, 300, 80, 80), proposedDx: 217, proposedDy: 0, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dx).toBe(220); // proposed left=220, proposed right=300 }); test("bottom-to-bottom alignment", () => { // Target bottom = 200. Moving rect height=50 at y=0, bottom=50. // Propose dy=147 => proposed bottom=197, target bottom=200, diff=3. const t = target("a", 0, 0, 200, 200); const result = resolveSnapAdjustment({ movingRect: rect(0, 0, 50, 50), proposedDx: 0, proposedDy: 147, targets: [t], threshold: SNAP_THRESHOLD_PX, disabled: false, }); expect(result.dy).toBe(150); }); });