hangwin--mcp-chrome
874 行
28 KiB
TypeScript
874 行
28 KiB
TypeScript
/**
|
|
* 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>): 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>,
|
|
): 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([]);
|
|
});
|
|
});
|
|
});
|