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)
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user