Files

341 lines
11 KiB
TypeScript
Raw Permalink Normal View History

/**
* SnapEngine Tests Snapping modes, grid snap, polar tracking
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SnapEngine } from '../src/canvas/SnapEngine';
import type { SnapMode, SnapConfig } from '../src/canvas/SnapEngine';
import type { CADElement } from '../src/types/cad.types';
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number): CADElement {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return {
id,
type: 'line',
layerId: 'layer-1',
x: cx,
y: cy,
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2 },
};
}
function makeCircle(id: string, cx: number, cy: number, r: number): CADElement {
return {
id,
type: 'circle',
layerId: 'layer-1',
x: cx,
y: cy,
width: r * 2,
height: r * 2,
properties: { radius: r },
};
}
function makeRect(id: string, cx: number, cy: number, w: number, h: number): CADElement {
return {
id,
type: 'rect',
layerId: 'layer-1',
x: cx,
y: cy,
width: w,
height: h,
properties: {},
};
}
describe('SnapEngine', () => {
let engine: SnapEngine;
beforeEach(() => {
engine = new SnapEngine();
});
describe('constructor & config', () => {
it('should have default config with enabled=true', () => {
const cfg = engine.getConfig();
expect(cfg.enabled).toBe(true);
});
it('should have default modes including endpoint, midpoint, center, intersection, nearest', () => {
const cfg = engine.getConfig();
expect(cfg.modes.has('endpoint')).toBe(true);
expect(cfg.modes.has('midpoint')).toBe(true);
expect(cfg.modes.has('center')).toBe(true);
expect(cfg.modes.has('intersection')).toBe(true);
expect(cfg.modes.has('nearest')).toBe(true);
});
it('should accept custom config overrides', () => {
const eng = new SnapEngine({ tolerance: 25, gridSpacing: 50 });
const cfg = eng.getConfig();
expect(cfg.tolerance).toBe(25);
expect(cfg.gridSpacing).toBe(50);
});
});
describe('setConfig & toggleMode', () => {
it('should update config partially', () => {
engine.setConfig({ tolerance: 30 });
expect(engine.getConfig().tolerance).toBe(30);
});
it('should toggle mode on/off', () => {
expect(engine.getConfig().modes.has('endpoint')).toBe(true);
engine.toggleMode('endpoint');
expect(engine.getConfig().modes.has('endpoint')).toBe(false);
engine.toggleMode('endpoint');
expect(engine.getConfig().modes.has('endpoint')).toBe(true);
});
it('should toggle grid mode on', () => {
engine.toggleMode('grid');
expect(engine.getConfig().modes.has('grid')).toBe(true);
});
});
describe('snap disabled', () => {
it('should return null point when disabled', () => {
engine.setConfig({ enabled: false });
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(0, 0);
expect(result.point).toBeNull();
expect(result.preview).toEqual([]);
});
it('should return null point when no modes are active', () => {
const eng = new SnapEngine({
modes: new Set<SnapMode>(),
});
eng.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = eng.snap(0, 0);
expect(result.point).toBeNull();
});
});
describe('snap endpoint mode', () => {
it('should snap to line endpoint when within tolerance', () => {
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(2, 2);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(0);
expect(result.point!.y).toBe(0);
expect(result.point!.type).toBe('endpoint');
});
it('should snap to the second endpoint of a line', () => {
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(98, 1);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(100);
expect(result.point!.y).toBe(0);
expect(result.point!.type).toBe('endpoint');
});
it('should not snap when outside tolerance', () => {
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
engine.setConfig({ tolerance: 5 });
const result = engine.snap(50, 20);
expect(result.point).toBeNull();
});
it('should snap to rect corners', () => {
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
const result = engine.snap(32, 32);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(30);
expect(result.point!.y).toBe(30);
expect(result.point!.type).toBe('endpoint');
});
});
describe('snap midpoint mode', () => {
it('should snap to line midpoint', () => {
engine.setConfig({ modes: new Set<SnapMode>(['midpoint']) });
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(50, 2);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(50);
expect(result.point!.y).toBe(0);
expect(result.point!.type).toBe('midpoint');
});
it('should snap to rect edge midpoints', () => {
engine.setConfig({ modes: new Set<SnapMode>(['midpoint']) });
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
const result = engine.snap(50, 31);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(50);
expect(result.point!.y).toBe(30);
expect(result.point!.type).toBe('midpoint');
});
});
describe('snap center mode', () => {
it('should snap to circle center', () => {
engine.setConfig({ modes: new Set<SnapMode>(['center']) });
engine.setElements([makeCircle('c1', 50, 50, 30)]);
const result = engine.snap(52, 48);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(50);
expect(result.point!.y).toBe(50);
expect(result.point!.type).toBe('center');
});
it('should snap to rect center', () => {
engine.setConfig({ modes: new Set<SnapMode>(['center']) });
engine.setElements([makeRect('r1', 50, 50, 40, 40)]);
const result = engine.snap(48, 52);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(50);
expect(result.point!.y).toBe(50);
expect(result.point!.type).toBe('center');
});
});
describe('snap grid mode', () => {
it('should snap to nearest grid point', () => {
engine.setConfig({
modes: new Set<SnapMode>(['grid']),
gridSpacing: 20,
tolerance: 10,
});
const result = engine.snap(18, 2);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(20);
expect(result.point!.y).toBe(0);
});
it('should snap to grid point at origin', () => {
engine.setConfig({
modes: new Set<SnapMode>(['grid']),
gridSpacing: 20,
tolerance: 10,
});
const result = engine.snap(3, 3);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(0);
expect(result.point!.y).toBe(0);
});
it('should not snap when too far from grid point', () => {
engine.setConfig({
modes: new Set<SnapMode>(['grid']),
gridSpacing: 20,
tolerance: 5,
});
const result = engine.snap(13, 13);
expect(result.point).toBeNull();
});
});
describe('snap nearest mode', () => {
it('should snap to nearest point on a line', () => {
engine.setConfig({ modes: new Set<SnapMode>(['nearest']) });
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(50, 5);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBe(50);
expect(result.point!.y).toBe(0);
expect(result.point!.type).toBe('nearest');
});
});
describe('snap intersection mode', () => {
it('should snap to intersection of two lines', () => {
engine.setConfig({ modes: new Set<SnapMode>(['intersection']), tolerance: 10 });
engine.setElements([
makeLine('l1', 0, 0, 100, 100),
makeLine('l2', 0, 100, 100, 0),
]);
const result = engine.snap(52, 50);
expect(result.point).not.toBeNull();
expect(result.point!.x).toBeCloseTo(50, 0);
expect(result.point!.y).toBeCloseTo(50, 0);
expect(result.point!.type).toBe('intersection');
});
});
describe('snap priority', () => {
it('should prefer endpoint over grid when both are in range', () => {
engine.setConfig({
modes: new Set<SnapMode>(['endpoint', 'grid']),
gridSpacing: 20,
tolerance: 10,
});
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(2, 2);
expect(result.point).not.toBeNull();
expect(result.point!.type).toBe('endpoint');
});
});
describe('snap preview', () => {
it('should return preview candidates', () => {
engine.setElements([makeLine('l1', 0, 0, 100, 0)]);
const result = engine.snap(2, 2);
expect(result.preview.length).toBeGreaterThan(0);
});
});
describe('polar tracking', () => {
it('should snap to 0-degree polar angle from reference point', () => {
engine.setConfig({
polarEnabled: true,
polarAngles: [0, 90, 180, 270],
polarTolerance: 5,
modes: new Set<SnapMode>(['nearest']),
});
// Reference at (0,0), cursor near 0 degrees (horizontal right) at distance ~100
const result = engine.snap(100, 5, { x: 0, y: 0 });
expect(result.point).not.toBeNull();
if (result.point) {
expect(result.point.y).toBeCloseTo(0, 0);
}
});
it('should snap to 90-degree polar angle from reference point', () => {
engine.setConfig({
polarEnabled: true,
polarAngles: [0, 90, 180, 270],
polarTolerance: 5,
modes: new Set<SnapMode>(['nearest']),
});
// Reference at (0,0), cursor near 90 degrees (down) at distance ~100
const result = engine.snap(5, 100, { x: 0, y: 0 });
expect(result.point).not.toBeNull();
if (result.point) {
expect(result.point.x).toBeCloseTo(0, 0);
}
});
it('should not polar-snap when polarEnabled is false', () => {
engine.setConfig({
polarEnabled: false,
modes: new Set<SnapMode>(['nearest']),
});
engine.setElements([makeLine('l1', 0, 0, 200, 0)]);
const result = engine.snap(100, 5, { x: 0, y: 0 });
// Should snap to nearest on line, not polar
if (result.point) {
expect(result.point.y).toBe(0);
}
});
it('should not polar-snap when cursor too close to reference', () => {
engine.setConfig({
polarEnabled: true,
polarAngles: [0],
polarTolerance: 5,
modes: new Set<SnapMode>(['nearest']),
});
const result = engine.snap(0.5, 0.5, { x: 0, y: 0 });
// dist < 1, so no polar snap; no elements either, so null
expect(result.point).toBeNull();
});
});
});