Files
web-cad/frontend/tests/SnapEngine.test.ts
T
Leopoldadmin 1bcfaffdd4 fix: replace hardcoded localhost:3001 with relative API URL + add canvas mock and integration test
- 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)
2026-06-26 17:48:24 +02:00

341 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});
});