/** * 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 { 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; 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'); }); }); });