/** * Unit tests for Web Editor V2 Snap Engine * * Tests cover: * - mergeAnchors: Anchor collection merging * - computeResizeSnap: Snap computation during resize * - computeDistanceLabels: Distance label generation * * All functions tested here are pure functions with no DOM dependencies, * making them ideal for unit testing. */ import { describe, expect, it } from 'vitest'; import { computeDistanceLabels, computeResizeSnap, mergeAnchors, type ComputeDistanceLabelsParams, type ComputeResizeSnapParams, type SnapAnchors, type SnapLockX, type SnapLockY, } from '@/entrypoints/web-editor-v2/core/snap-engine'; import type { ViewportRect } from '@/entrypoints/web-editor-v2/overlay/canvas-overlay'; // ============================================================================= // Test Utilities // ============================================================================= /** * Creates a ViewportRect from coordinates and dimensions. */ function rect(left: number, top: number, width: number, height: number): ViewportRect { return { left, top, width, height }; } /** * Default viewport dimensions for tests. */ const VIEWPORT = { width: 800, height: 600 }; /** * Creates default params for computeResizeSnap tests. */ function createSnapParams(overrides: Partial): ComputeResizeSnapParams { return { rect: rect(100, 100, 200, 150), resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [], y: [] }, thresholdPx: 6, hysteresisPx: 2, minSizePx: 10, lockX: null, lockY: null, viewport: VIEWPORT, ...overrides, }; } /** * Creates default params for computeDistanceLabels tests. */ function createLabelParams( overrides: Partial, ): ComputeDistanceLabelsParams { return { rect: rect(100, 100, 200, 150), lockX: null, lockY: null, viewport: VIEWPORT, minGapPx: 1, ...overrides, }; } // ============================================================================= // mergeAnchors Tests // ============================================================================= describe('snap-engine: mergeAnchors', () => { it('returns empty anchors when called with no arguments', () => { const result = mergeAnchors(); expect(result).toEqual({ x: [], y: [] }); }); it('returns the same anchors when called with single collection', () => { const anchors: SnapAnchors = { x: [{ type: 'left', value: 0, source: 'viewport' }], y: [{ type: 'top', value: 0, source: 'viewport' }], }; const result = mergeAnchors(anchors); expect(result.x).toHaveLength(1); expect(result.y).toHaveLength(1); }); it('concatenates anchors from multiple collections in order', () => { const collection1: SnapAnchors = { x: [{ type: 'left', value: 0, source: 'viewport' }], y: [{ type: 'top', value: 0, source: 'viewport' }], }; const collection2: SnapAnchors = { x: [{ type: 'center', value: 50, source: 'sibling', sourceRect: rect(40, 0, 20, 20) }], y: [], }; const collection3: SnapAnchors = { x: [{ type: 'right', value: 100, source: 'sibling', sourceRect: rect(80, 0, 20, 20) }], y: [{ type: 'bottom', value: 100, source: 'viewport' }], }; const result = mergeAnchors(collection1, collection2, collection3); expect(result.x).toHaveLength(3); expect(result.y).toHaveLength(2); // Verify order is preserved expect(result.x[0]).toMatchObject({ type: 'left', value: 0 }); expect(result.x[1]).toMatchObject({ type: 'center', value: 50 }); expect(result.x[2]).toMatchObject({ type: 'right', value: 100 }); }); it('handles empty collections gracefully', () => { const empty: SnapAnchors = { x: [], y: [] }; const nonEmpty: SnapAnchors = { x: [{ type: 'left', value: 10, source: 'viewport' }], y: [], }; const result = mergeAnchors(empty, nonEmpty, empty); expect(result.x).toHaveLength(1); expect(result.y).toHaveLength(0); }); }); // ============================================================================= // computeResizeSnap Tests // ============================================================================= describe('snap-engine: computeResizeSnap', () => { describe('basic snapping', () => { it('snaps west edge within threshold and emits vertical guide line', () => { const params = createSnapParams({ rect: rect(103, 100, 197, 150), // left edge at 103 resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'left', value: 100, source: 'viewport' }], // anchor at 100 y: [], }, }); const result = computeResizeSnap(params); // Should snap left edge from 103 to 100 (distance 3 < threshold 6) expect(result.snappedRect.left).toBe(100); expect(result.snappedRect.width).toBe(200); // width adjusted expect(result.lockX).toMatchObject({ type: 'left', value: 100, source: 'viewport' }); expect(result.lockY).toBeNull(); expect(result.guideLines).toHaveLength(1); expect(result.guideLines[0]).toEqual({ x1: 100, y1: 0, x2: 100, y2: VIEWPORT.height }); }); it('snaps east edge within threshold', () => { const params = createSnapParams({ rect: rect(100, 100, 197, 150), // right edge at 297 resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'right', value: 300, source: 'viewport' }], // anchor at 300 y: [], }, }); const result = computeResizeSnap(params); // Should snap right edge from 297 to 300 expect(result.snappedRect.left).toBe(100); // left unchanged expect(result.snappedRect.width).toBe(200); expect(result.lockX).toMatchObject({ type: 'right', value: 300 }); }); it('snaps north edge within threshold and emits horizontal guide line', () => { const params = createSnapParams({ rect: rect(100, 104, 200, 146), // top edge at 104 resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false }, anchors: { x: [], y: [{ type: 'top', value: 100, source: 'viewport' }], }, }); const result = computeResizeSnap(params); expect(result.snappedRect.top).toBe(100); expect(result.snappedRect.height).toBe(150); expect(result.lockY).toMatchObject({ type: 'top', value: 100 }); expect(result.guideLines).toHaveLength(1); expect(result.guideLines[0]).toEqual({ x1: 0, y1: 100, x2: VIEWPORT.width, y2: 100 }); }); it('snaps south edge within threshold', () => { const params = createSnapParams({ rect: rect(100, 100, 200, 147), // bottom edge at 247 resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true }, anchors: { x: [], y: [{ type: 'bottom', value: 250, source: 'viewport' }], }, }); const result = computeResizeSnap(params); expect(result.snappedRect.top).toBe(100); expect(result.snappedRect.height).toBe(150); expect(result.lockY).toMatchObject({ type: 'bottom', value: 250 }); }); }); describe('threshold behavior', () => { it('does not snap when distance exceeds threshold', () => { const params = createSnapParams({ rect: rect(100, 100, 200, 150), resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'left', value: 90, source: 'viewport' }], // distance 10 > threshold 6 y: [], }, }); const result = computeResizeSnap(params); expect(result.snappedRect).toEqual(params.rect); expect(result.lockX).toBeNull(); expect(result.guideLines).toEqual([]); }); it('snaps at exactly the threshold distance', () => { const params = createSnapParams({ rect: rect(106, 100, 194, 150), resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'left', value: 100, source: 'viewport' }], // distance exactly 6 y: [], }, }); const result = computeResizeSnap(params); expect(result.snappedRect.left).toBe(100); expect(result.lockX).not.toBeNull(); }); }); describe('anchor priority', () => { it('prefers sibling anchors over viewport anchors at equal distance', () => { // Both anchors at same value (100), same type (left), same distance from rect.left (103) // When hasWest: true, we're moving the left edge, so 'left' type anchors are allowed const siblingRect = rect(50, 0, 50, 50); const params = createSnapParams({ rect: rect(103, 100, 197, 150), // left edge at 103 resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [ { type: 'left', value: 100, source: 'viewport' }, // distance 3, left type { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type ], y: [], }, }); const result = computeResizeSnap(params); // Sibling should be preferred over viewport at equal distance expect(result.lockX?.source).toBe('sibling'); expect(result.snappedRect.left).toBe(100); }); it('chooses closest anchor regardless of source when distances differ', () => { // Both anchors have 'left' type (allowed for hasWest resize), but different distances const siblingRect = rect(50, 0, 50, 50); const params = createSnapParams({ rect: rect(103, 100, 197, 150), // left edge at 103 resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [ { type: 'left', value: 102, source: 'viewport' }, // distance 1, left type { type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }, // distance 3, left type ], y: [], }, }); const result = computeResizeSnap(params); // Closer anchor (viewport at 102) should be chosen despite sibling having priority at equal distance expect(result.lockX?.source).toBe('viewport'); expect(result.snappedRect.left).toBe(102); }); }); describe('hysteresis (lock stability)', () => { it('maintains existing lock within threshold + hysteresis', () => { const lockX: SnapLockX = { type: 'left', value: 100, source: 'viewport', sourceRect: null, }; const params = createSnapParams({ rect: rect(107, 100, 193, 150), // distance 7 from lock (threshold 6 + hysteresis 2 = 8) resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [], y: [] }, lockX, }); const result = computeResizeSnap(params); expect(result.lockX).toMatchObject({ type: 'left', value: 100 }); expect(result.snappedRect.left).toBe(100); // still snapped }); it('releases lock when distance exceeds threshold + hysteresis', () => { const lockX: SnapLockX = { type: 'left', value: 100, source: 'viewport', sourceRect: null, }; const params = createSnapParams({ rect: rect(109, 100, 191, 150), // distance 9 > threshold 6 + hysteresis 2 resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [], y: [] }, lockX, }); const result = computeResizeSnap(params); expect(result.lockX).toBeNull(); expect(result.snappedRect.left).toBe(109); // no snap }); }); describe('minimum size constraint', () => { it('rejects snap that would violate minimum width', () => { const params = createSnapParams({ rect: rect(100, 100, 20, 150), resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'left', value: 115, source: 'viewport' }], // would make width = 5 y: [], }, minSizePx: 10, thresholdPx: 20, // large threshold to ensure snap would trigger }); const result = computeResizeSnap(params); expect(result.lockX).toBeNull(); expect(result.snappedRect).toEqual(params.rect); }); it('rejects snap that would violate minimum height', () => { const params = createSnapParams({ rect: rect(100, 100, 200, 15), resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false }, anchors: { x: [], y: [{ type: 'top', value: 110, source: 'viewport' }], // would make height = 5 }, minSizePx: 10, thresholdPx: 20, }); const result = computeResizeSnap(params); expect(result.lockY).toBeNull(); }); }); describe('invalid rect handling', () => { it('returns unchanged rect and clears locks for zero-width rect', () => { const lockX: SnapLockX = { type: 'left', value: 0, source: 'viewport', sourceRect: null }; const params = createSnapParams({ rect: rect(0, 0, 0, 100), lockX, lockY: null, }); const result = computeResizeSnap(params); expect(result.snappedRect).toEqual(params.rect); expect(result.lockX).toBeNull(); expect(result.lockY).toBeNull(); expect(result.guideLines).toEqual([]); }); it('returns unchanged rect for zero-height rect', () => { const params = createSnapParams({ rect: rect(0, 0, 100, 0), }); const result = computeResizeSnap(params); expect(result.snappedRect).toEqual(params.rect); }); }); describe('multi-direction resize', () => { it('snaps both X and Y axes simultaneously', () => { const params = createSnapParams({ rect: rect(103, 97, 197, 153), resize: { hasWest: true, hasEast: false, hasNorth: true, hasSouth: false }, anchors: { x: [{ type: 'left', value: 100, source: 'viewport' }], y: [{ type: 'top', value: 100, source: 'viewport' }], }, }); const result = computeResizeSnap(params); expect(result.snappedRect.left).toBe(100); expect(result.snappedRect.top).toBe(100); expect(result.lockX).not.toBeNull(); expect(result.lockY).not.toBeNull(); expect(result.guideLines).toHaveLength(2); }); }); describe('center/middle anchor snapping', () => { it('snaps to center anchor when resizing from west (left is fixed)', () => { // When hasEast: true, fixedEdgeX = 'left', allowedTypes = ['right', 'center'] const params = createSnapParams({ rect: rect(100, 100, 198, 150), // center at 199, right at 298 resize: { hasWest: false, hasEast: true, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'center', value: 200, source: 'viewport' }], // distance 1 y: [], }, }); const result = computeResizeSnap(params); // Center snapped to 200, so width = (200 - 100) * 2 = 200 expect(result.snappedRect.left).toBe(100); // left unchanged expect(result.snappedRect.width).toBe(200); expect(result.lockX).toMatchObject({ type: 'center', value: 200 }); }); it('snaps to middle anchor when resizing from south', () => { // When hasSouth: true, fixedEdgeY = 'top', allowedTypes = ['bottom', 'middle'] const params = createSnapParams({ rect: rect(100, 100, 200, 148), // middle at 174, bottom at 248 resize: { hasWest: false, hasEast: false, hasNorth: false, hasSouth: true }, anchors: { x: [], y: [{ type: 'middle', value: 175, source: 'viewport' }], // distance 1 }, }); const result = computeResizeSnap(params); // Middle snapped to 175, so height = (175 - 100) * 2 = 150 expect(result.snappedRect.top).toBe(100); expect(result.snappedRect.height).toBe(150); expect(result.lockY).toMatchObject({ type: 'middle', value: 175 }); }); }); describe('lock invalidation', () => { it('clears lock when its type is not allowed for current resize direction', () => { // Lock was on 'right' but now we're resizing from east (which allows right/center) // When hasWest: true, only 'left' and 'center' are allowed const lockX: SnapLockX = { type: 'right', value: 300, source: 'viewport', sourceRect: null, }; const params = createSnapParams({ rect: rect(100, 100, 200, 150), resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [], y: [] }, lockX, }); const result = computeResizeSnap(params); // Lock should be cleared because 'right' is not in allowed types for west resize expect(result.lockX).toBeNull(); }); it('clears lock when axis is not being resized', () => { // X-axis lock but no X resize is happening const lockX: SnapLockX = { type: 'left', value: 100, source: 'viewport', sourceRect: null, }; const params = createSnapParams({ rect: rect(100, 100, 200, 150), resize: { hasWest: false, hasEast: false, hasNorth: true, hasSouth: false }, // only Y resize anchors: { x: [], y: [] }, lockX, }); const result = computeResizeSnap(params); expect(result.lockX).toBeNull(); }); }); describe('sibling guide line extent', () => { it('generates guide line spanning from source to target element for sibling snap', () => { const siblingRect = rect(50, 20, 50, 60); // right edge at 100, bottom at 80 const params = createSnapParams({ rect: rect(103, 100, 197, 150), // top at 100, bottom at 250 resize: { hasWest: true, hasEast: false, hasNorth: false, hasSouth: false }, anchors: { x: [{ type: 'left', value: 100, source: 'sibling', sourceRect: siblingRect }], y: [], }, }); const result = computeResizeSnap(params); expect(result.guideLines).toHaveLength(1); // Guide line should span from sibling's vertical extent to target's vertical extent // min(sibling.top, target.top) to max(sibling.bottom, target.bottom) expect(result.guideLines[0]).toEqual({ x1: 100, y1: Math.min(siblingRect.top, 100), // 20 x2: 100, y2: Math.max(80, 250), // 250 }); }); }); }); // ============================================================================= // computeDistanceLabels Tests // ============================================================================= describe('snap-engine: computeDistanceLabels', () => { describe('sibling gap labels', () => { it('computes vertical gap from X-axis sibling lock', () => { const sourceRect = rect(100, 30, 50, 50); // bottom at 80 const lockX: SnapLockX = { type: 'left', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 200, 150), // top at 100, gap = 20 lockX, }); const labels = computeDistanceLabels(params); expect(labels).toHaveLength(1); expect(labels[0]).toMatchObject({ kind: 'sibling', axis: 'y', value: 20, text: '20px', }); expect(labels[0]?.line).toEqual({ x1: 100, y1: 80, x2: 100, y2: 100 }); }); it('computes horizontal gap from Y-axis sibling lock', () => { const sourceRect = rect(30, 100, 50, 50); // right at 80 const lockY: SnapLockY = { type: 'top', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 200, 150), // left at 100, gap = 20 lockY, }); const labels = computeDistanceLabels(params); expect(labels).toHaveLength(1); expect(labels[0]).toMatchObject({ kind: 'sibling', axis: 'x', value: 20, text: '20px', }); expect(labels[0]?.line).toEqual({ x1: 80, y1: 100, x2: 100, y2: 100 }); }); it('hides labels for gaps below minGapPx', () => { const sourceRect = rect(100, 99.5, 50, 0.3); // bottom at 99.8 const lockX: SnapLockX = { type: 'left', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 200, 150), // gap = 0.2 lockX, minGapPx: 1, }); const labels = computeDistanceLabels(params); expect(labels).toEqual([]); }); it('hides labels for zero gap (touching elements)', () => { const sourceRect = rect(100, 50, 50, 50); // bottom at 100 const lockX: SnapLockX = { type: 'left', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 200, 150), // top at 100, gap = 0 lockX, }); const labels = computeDistanceLabels(params); expect(labels).toEqual([]); }); it('computes vertical gap when target is above source (reverse direction)', () => { const sourceRect = rect(100, 150, 50, 50); // top at 150 const lockX: SnapLockX = { type: 'left', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 50, 200, 80), // bottom at 130, gap = 20 lockX, }); const labels = computeDistanceLabels(params); expect(labels).toHaveLength(1); expect(labels[0]).toMatchObject({ kind: 'sibling', axis: 'y', value: 20, text: '20px', }); }); it('computes horizontal gap when target is left of source (reverse direction)', () => { const sourceRect = rect(250, 100, 50, 50); // left at 250 const lockY: SnapLockY = { type: 'top', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 100, 150), // right at 200, gap = 50 lockY, }); const labels = computeDistanceLabels(params); expect(labels).toHaveLength(1); expect(labels[0]).toMatchObject({ kind: 'sibling', axis: 'x', value: 50, text: '50px', }); }); it('hides labels for overlapping elements (negative gap)', () => { const sourceRect = rect(100, 80, 50, 50); // bottom at 130 const lockX: SnapLockX = { type: 'left', value: 100, source: 'sibling', sourceRect, }; const params = createLabelParams({ rect: rect(100, 100, 200, 150), // top at 100, overlaps with source lockX, }); const labels = computeDistanceLabels(params); // Negative gap (overlap) should not produce labels expect(labels).toEqual([]); }); }); describe('viewport margin labels', () => { it('shows viewport margin for X-axis viewport lock (left align)', () => { const lockX: SnapLockX = { type: 'left', value: 50, source: 'viewport', sourceRect: null, }; const params = createLabelParams({ rect: rect(50, 100, 200, 150), // left=50, right=250, center at y=175 lockX, viewport: { width: 800, height: 600 }, }); const labels = computeDistanceLabels(params); expect(labels.length).toBeGreaterThanOrEqual(1); const viewportLabel = labels.find((l) => l.kind === 'viewport'); expect(viewportLabel).toBeDefined(); expect(viewportLabel).toMatchObject({ kind: 'viewport', axis: 'x', value: 50, // left margin text: '50px', }); // Line should be horizontal from left edge of viewport to left edge of rect expect(viewportLabel?.line).toEqual({ x1: 0, y1: 175, x2: 50, y2: 175 }); }); it('shows opposite margin when aligned margin is 0', () => { const lockX: SnapLockX = { type: 'left', value: 0, source: 'viewport', sourceRect: null, }; const params = createLabelParams({ rect: rect(0, 100, 200, 150), // left margin = 0, right margin = 600 lockX, viewport: { width: 800, height: 600 }, }); const labels = computeDistanceLabels(params); const viewportLabel = labels.find((l) => l.kind === 'viewport'); expect(viewportLabel).toBeDefined(); // Should show the right margin since left is 0 expect(viewportLabel?.value).toBe(600); }); it('shows viewport margin for Y-axis viewport lock (top align)', () => { const lockY: SnapLockY = { type: 'top', value: 50, source: 'viewport', sourceRect: null, }; const params = createLabelParams({ rect: rect(100, 50, 200, 150), // top=50, bottom=200, center at x=200 lockY, viewport: { width: 800, height: 600 }, }); const labels = computeDistanceLabels(params); expect(labels.length).toBeGreaterThanOrEqual(1); const viewportLabel = labels.find((l) => l.kind === 'viewport'); expect(viewportLabel).toBeDefined(); expect(viewportLabel).toMatchObject({ kind: 'viewport', axis: 'y', value: 50, // top margin text: '50px', }); }); it('shows both margins for center lock (X-axis)', () => { const lockX: SnapLockX = { type: 'center', value: 400, // viewport center source: 'viewport', sourceRect: null, }; const params = createLabelParams({ rect: rect(300, 100, 200, 150), // left=300, right=500, center=400 lockX, viewport: { width: 800, height: 600 }, }); const labels = computeDistanceLabels(params); // Center lock should produce 2 viewport labels (left and right margins) const viewportLabels = labels.filter((l) => l.kind === 'viewport'); expect(viewportLabels).toHaveLength(2); expect(viewportLabels.map((l) => l.value).sort()).toEqual([300, 300]); }); it('shows both margins for middle lock (Y-axis)', () => { const lockY: SnapLockY = { type: 'middle', value: 300, // viewport middle source: 'viewport', sourceRect: null, }; const params = createLabelParams({ rect: rect(100, 225, 200, 150), // top=225, bottom=375, middle=300 lockY, viewport: { width: 800, height: 600 }, }); const labels = computeDistanceLabels(params); // Middle lock should produce 2 viewport labels (top and bottom margins) const viewportLabels = labels.filter((l) => l.kind === 'viewport'); expect(viewportLabels).toHaveLength(2); expect(viewportLabels.map((l) => l.value).sort()).toEqual([225, 225]); }); }); describe('invalid rect handling', () => { it('returns empty labels for zero-width rect', () => { const params = createLabelParams({ rect: rect(0, 0, 0, 100), }); const labels = computeDistanceLabels(params); expect(labels).toEqual([]); }); it('returns empty labels for zero-height rect', () => { const params = createLabelParams({ rect: rect(0, 0, 100, 0), }); const labels = computeDistanceLabels(params); expect(labels).toEqual([]); }); }); describe('no lock state', () => { it('returns empty labels when no locks are active', () => { const params = createLabelParams({ lockX: null, lockY: null, }); const labels = computeDistanceLabels(params); expect(labels).toEqual([]); }); }); });