Files
web-cad/frontend/tests/SelectionEngine.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

395 lines
13 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.
/**
* SelectionEngine Tests Selection modes, filters, box selection, hover, listeners
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SelectionEngine } from '../src/canvas/SelectionEngine';
import { RenderEngine } from '../src/canvas/RenderEngine';
import { SpatialIndex } from '../src/canvas/SpatialIndex';
import { LayerManager } from '../src/canvas/LayerManager';
import { ZoomPanController } from '../src/canvas/ZoomPanController';
import type { CADElement, CADLayer } from '../src/types/cad.types';
function createMockCanvas(w = 800, h = 600): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillRect = (() => {}) as any;
ctx.strokeRect = (() => {}) as any;
ctx.beginPath = (() => {}) as any;
ctx.moveTo = (() => {}) as any;
ctx.lineTo = (() => {}) as any;
ctx.stroke = (() => {}) as any;
ctx.fill = (() => {}) as any;
ctx.save = (() => {}) as any;
ctx.restore = (() => {}) as any;
ctx.scale = (() => {}) as any;
ctx.arc = (() => {}) as any;
ctx.setLineDash = (() => {}) as any;
ctx.clearRect = (() => {}) as any;
ctx.translate = (() => {}) as any;
ctx.rotate = (() => {}) as any;
ctx.clip = (() => {}) as any;
ctx.fillText = (() => {}) as any;
ctx.quadraticCurveTo = (() => {}) as any;
ctx.closePath = (() => {}) as any;
}
return canvas;
}
function makeLayer(id: string, overrides: Partial<CADLayer> = {}): CADLayer {
return {
id,
name: id,
visible: true,
locked: false,
color: '#ffffff',
lineType: 'solid',
transparency: 0,
sortOrder: 0,
parentId: null,
...overrides,
};
}
function makeLine(id: string, x1: number, y1: number, x2: number, y2: number, layerId = 'layer-1'): CADElement {
return {
id,
type: 'line',
layerId,
x: (x1 + x2) / 2,
y: (y1 + y2) / 2,
width: Math.abs(x2 - x1),
height: Math.abs(y2 - y1),
properties: { x1, y1, x2, y2 },
};
}
function makeRect(id: string, cx: number, cy: number, w: number, h: number, layerId = 'layer-1'): CADElement {
return {
id,
type: 'rect',
layerId,
x: cx,
y: cy,
width: w,
height: h,
properties: {},
};
}
function setup(): {
canvas: HTMLCanvasElement;
zpc: ZoomPanController;
spatialIndex: SpatialIndex;
layerManager: LayerManager;
renderEngine: RenderEngine;
selectionEngine: SelectionEngine;
} {
const canvas = createMockCanvas(800, 600);
const zpc = new ZoomPanController(canvas);
const spatialIndex = new SpatialIndex();
const layerManager = new LayerManager();
layerManager.addLayer(makeLayer('layer-1'));
const renderEngine = new RenderEngine(canvas, zpc, spatialIndex, layerManager);
const selectionEngine = new SelectionEngine(renderEngine, spatialIndex, layerManager);
return { canvas, zpc, spatialIndex, layerManager, renderEngine, selectionEngine };
}
describe('SelectionEngine', () => {
let s: ReturnType<typeof setup>;
beforeEach(() => {
s = setup();
});
describe('initial state', () => {
it('should have empty selection', () => {
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
});
it('should have default options (single mode, all filter)', () => {
const opts = s.selectionEngine.getOptions();
expect(opts.mode).toBe('single');
expect(opts.filter).toBe('all');
expect(opts.additive).toBe(false);
expect(opts.subtractive).toBe(false);
});
it('should have null hover', () => {
expect(s.selectionEngine.getHoverId()).toBeNull();
});
});
describe('setOptions', () => {
it('should update options partially', () => {
s.selectionEngine.setOptions({ filter: 'lines' });
expect(s.selectionEngine.getOptions().filter).toBe('lines');
});
});
describe('clickSelect hit', () => {
it('should select an element when clicking on it', () => {
const line = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(line);
const result = s.selectionEngine.clickSelect(100, 50, [line]);
expect(result).not.toBeNull();
expect(result!.id).toBe('l1');
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true);
});
it('should select a rect element when clicking within it', () => {
const rect = makeRect('r1', 100, 100, 80, 60);
s.spatialIndex.insert(rect);
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
expect(result).not.toBeNull();
expect(result!.id).toBe('r1');
});
});
describe('clickSelect miss', () => {
it('should return null when clicking empty space', () => {
const line = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(line);
const result = s.selectionEngine.clickSelect(400, 400, [line]);
expect(result).toBeNull();
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
});
it('should clear selection when clicking empty space in non-additive mode', () => {
const line = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(line);
s.selectionEngine.clickSelect(100, 50, [line]);
expect(s.selectionEngine.getSelectedIds().size).toBe(1);
s.selectionEngine.clickSelect(400, 400, [line]);
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
});
});
describe('clickSelect additive mode', () => {
it('should add to selection in additive mode', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
const l2 = makeLine('l2', 50, 100, 150, 100);
s.spatialIndex.insert(l1);
s.spatialIndex.insert(l2);
s.selectionEngine.setOptions({ additive: true });
s.selectionEngine.clickSelect(100, 50, [l1, l2]);
s.selectionEngine.clickSelect(100, 100, [l1, l2]);
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
});
it('should not clear on miss in additive mode', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(l1);
s.selectionEngine.setOptions({ additive: true });
s.selectionEngine.clickSelect(100, 50, [l1]);
s.selectionEngine.clickSelect(400, 400, [l1]);
expect(s.selectionEngine.getSelectedIds().size).toBe(1);
});
});
describe('clickSelect subtractive mode', () => {
it('should remove from selection in subtractive mode', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(l1);
// First select normally
s.selectionEngine.clickSelect(100, 50, [l1]);
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(true);
// Now subtract
s.selectionEngine.setOptions({ subtractive: true });
s.selectionEngine.clickSelect(100, 50, [l1]);
expect(s.selectionEngine.getSelectedIds().has('l1')).toBe(false);
});
});
describe('clickSelect filter modes', () => {
it('should not select when filter does not match', () => {
const rect = makeRect('r1', 100, 100, 80, 60);
s.spatialIndex.insert(rect);
s.selectionEngine.setOptions({ filter: 'lines' });
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
expect(result).toBeNull();
});
it('should select when filter matches lines', () => {
const line = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(line);
s.selectionEngine.setOptions({ filter: 'lines' });
const result = s.selectionEngine.clickSelect(100, 50, [line]);
expect(result).not.toBeNull();
expect(result!.id).toBe('l1');
});
it('should select rects with rects filter', () => {
const rect = makeRect('r1', 100, 100, 80, 60);
s.spatialIndex.insert(rect);
s.selectionEngine.setOptions({ filter: 'rects' });
const result = s.selectionEngine.clickSelect(100, 100, [rect]);
expect(result).not.toBeNull();
});
});
describe('box selection', () => {
it('should select elements within box (window mode)', () => {
const r1 = makeRect('r1', 100, 100, 40, 40);
const r2 = makeRect('r2', 300, 300, 40, 40);
s.spatialIndex.insert(r1);
s.spatialIndex.insert(r2);
// Window: left-to-right, fully enclosed
s.selectionEngine.startBoxSelect(50, 50);
s.selectionEngine.updateBoxSelect(200, 200, [r1, r2]);
const selected = s.selectionEngine.finishBoxSelect([r1, r2]);
// r1 bbox: 80,80 to 120,120 — fully within 50,50 to 200,200
expect(selected.length).toBe(1);
expect(selected[0].id).toBe('r1');
});
it('should select elements intersecting box (crossing mode)', () => {
const r1 = makeRect('r1', 100, 100, 40, 40);
s.spatialIndex.insert(r1);
// Crossing: right-to-left (start.x > end.x)
s.selectionEngine.startBoxSelect(200, 200);
s.selectionEngine.updateBoxSelect(90, 90, [r1]);
const selected = s.selectionEngine.finishBoxSelect([r1]);
expect(selected.length).toBe(1);
});
it('should clear box start/end after finish', () => {
s.selectionEngine.startBoxSelect(50, 50);
s.selectionEngine.updateBoxSelect(100, 100, []);
s.selectionEngine.finishBoxSelect([]);
expect(s.selectionEngine.isBoxSelecting()).toBe(false);
});
it('should return empty when no box started', () => {
const result = s.selectionEngine.finishBoxSelect([]);
expect(result).toEqual([]);
});
it('cancelBoxSelect should stop box selection', () => {
s.selectionEngine.startBoxSelect(50, 50);
s.selectionEngine.cancelBoxSelect();
expect(s.selectionEngine.isBoxSelecting()).toBe(false);
});
});
describe('clearSelection', () => {
it('should clear all selected ids', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(l1);
s.selectionEngine.clickSelect(100, 50, [l1]);
s.selectionEngine.clearSelection();
expect(s.selectionEngine.getSelectedIds().size).toBe(0);
});
});
describe('hover', () => {
it('should set and get hover id', () => {
s.selectionEngine.setHover('el-1');
expect(s.selectionEngine.getHoverId()).toBe('el-1');
});
it('should clear hover with null', () => {
s.selectionEngine.setHover('el-1');
s.selectionEngine.setHover(null);
expect(s.selectionEngine.getHoverId()).toBeNull();
});
});
describe('selectByIds', () => {
it('should select by ids', () => {
s.selectionEngine.selectByIds(['a', 'b', 'c']);
expect(s.selectionEngine.getSelectedIds().size).toBe(3);
});
it('should add to selection when additive=true', () => {
s.selectionEngine.selectByIds(['a']);
s.selectionEngine.selectByIds(['b'], true);
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
});
});
describe('selectAll', () => {
it('should select all visible elements matching filter', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
const l2 = makeLine('l2', 50, 100, 150, 100);
s.spatialIndex.insert(l1);
s.spatialIndex.insert(l2);
s.selectionEngine.selectAll([l1, l2]);
expect(s.selectionEngine.getSelectedIds().size).toBe(2);
});
});
describe('invertSelection', () => {
it('should invert current selection', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
const l2 = makeLine('l2', 50, 100, 150, 100);
s.spatialIndex.insert(l1);
s.spatialIndex.insert(l2);
s.selectionEngine.selectByIds(['l1']);
s.selectionEngine.invertSelection([l1, l2]);
const ids = s.selectionEngine.getSelectedIds();
expect(ids.has('l1')).toBe(false);
expect(ids.has('l2')).toBe(true);
});
});
describe('quickSelect', () => {
it('should select by type', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
const r1 = makeRect('r1', 100, 100, 80, 60);
const result = s.selectionEngine.quickSelect([l1, r1], { type: 'line' });
expect(result.length).toBe(1);
expect(result[0].id).toBe('l1');
});
it('should select by layerId', () => {
const l1 = makeLine('l1', 50, 50, 150, 50, 'layer-1');
const l2 = makeLine('l2', 50, 100, 150, 100, 'layer-2');
const result = s.selectionEngine.quickSelect([l1, l2], { layerId: 'layer-1' });
expect(result.length).toBe(1);
expect(result[0].id).toBe('l1');
});
});
describe('listeners', () => {
it('should call listener on selection change', () => {
let called = false;
let received: CADElement[] = [];
s.selectionEngine.addListener((els) => {
called = true;
received = els;
});
const l1 = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(l1);
s.selectionEngine.clickSelect(100, 50, [l1]);
expect(called).toBe(true);
expect(received.length).toBe(1);
expect(received[0].id).toBe('l1');
});
it('should remove listener', () => {
let called = false;
const fn = (els: CADElement[]) => { called = true; };
s.selectionEngine.addListener(fn);
s.selectionEngine.removeListener(fn);
const l1 = makeLine('l1', 50, 50, 150, 50);
s.spatialIndex.insert(l1);
s.selectionEngine.clickSelect(100, 50, [l1]);
expect(called).toBe(false);
});
});
describe('getSelectedElements', () => {
it('should filter allElements by selected ids', () => {
const l1 = makeLine('l1', 50, 50, 150, 50);
const l2 = makeLine('l2', 50, 100, 150, 100);
s.selectionEngine.selectByIds(['l1']);
const result = s.selectionEngine.getSelectedElements([l1, l2]);
expect(result.length).toBe(1);
expect(result[0].id).toBe('l1');
});
});
});