1bcfaffdd4
- Fix frontend Network error: API_BASE now defaults to empty string (same-origin) - Fix 3 files: api.ts, AuthContext.tsx, Dashboard.tsx - Add Canvas 2D context mock in tests/setup.ts for jsdom - Fix -0 vs +0 in ZoomPanController.getViewport() - Add IntegrationWorkflow.test.ts (35 tests, full CAD workflow)
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|
||
});
|