/** * Task CAD-3 - Fenster-/Kreuz-Selektion (Box-Select). * * boxSelectElements: reine Funktion. Links-nach-rechts ziehen = * 'window' (Element muss VOLLSTAENDIG in der Box liegen), * rechts-nach-links = 'crossing' (Element darf die Box beruehren). * Im selectTool: Leerer down startet Box-Drag, up selektiert. */ import { describe, it, expect, beforeEach } from 'vitest'; import { boxSelectElements, boxModeFor } from '../src/plugins/builtin/core-modify'; import { selectTool } from '../src/plugins/builtin/core-modify'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import type { CADElement, Pt } from '../src/types/cad.types'; function mkCtx(elements: CADElement[]) { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); for (const el of elements) doc.addElement(el); const selectedIds = new Set(); const previews: (CADElement | null)[] = []; const statuses: string[] = []; const ctx: any = { doc, options: {}, setStatus: (m: string) => statuses.push(m), setPreview: (el: CADElement | null) => previews.push(el), selection: { getIds: () => Array.from(selectedIds), setIds: (ids: string[]) => { selectedIds.clear(); for (const id of ids) selectedIds.add(id); }, hitTest: (x: number, y: number, _tol: number) => { for (const el of elements) { const p = el.properties as Record; if (p.x1 !== undefined) { const minX = Math.min(p.x1 as number, p.x2 as number); const maxX = Math.max(p.x1 as number, p.x2 as number); const minY = Math.min(p.y1 as number, p.y2 as number); const maxY = Math.max(p.y1 as number, p.y2 as number); if (x >= minX - 5 && x <= maxX + 5 && y >= minY - 5 && y <= maxY + 5) return el; } if ( x >= el.x - el.width / 2 && x <= el.x + el.width / 2 && y >= el.y - el.height / 2 && y <= el.y + el.height / 2 ) return el; } return null; }, }, }; return { ctx, doc, selectedIds, previews, statuses }; } function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } { return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never; } function rect(id: string, x: number, y: number, w: number, h: number): CADElement { return { id, type: 'rect', layerId: 'l', x, y, width: w, height: h, properties: {} } as unknown as CADElement; } function line(id: string, x1: number, y1: number, x2: number, y2: number): CADElement { return { id, type: 'line', layerId: 'l', x: (x1 + x2) / 2, y: (y1 + y2) / 2, width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), properties: { x1, y1, x2, y2 }, } as unknown as CADElement; } beforeEach(() => { selectTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); }); // ─── boxModeFor: Richtung bestimmt Modus ────────────────────── describe('CAD-3: boxModeFor', () => { it('Links-nach-rechts = window', () => { expect(boxModeFor({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe('window'); }); it('Rechts-nach-links = crossing', () => { expect(boxModeFor({ x: 100, y: 0 }, { x: 0, y: 100 })).toBe('crossing'); }); }); // ─── boxSelectElements: window (vollstaendig enthalten) ───── describe('CAD-3: window-Selektion (vollstaendig enthalten)', () => { it('Element komplett in Box wird selektiert', () => { const els = [rect('r1', 50, 50, 20, 20)]; const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; const hit = boxSelectElements(els, box, 'window'); expect(hit).toContain('r1'); }); it('Element, das die Box-Kante durchstoesst, wird NICHT selektiert (window)', () => { // rect 90..110, Box 0..100: ragt raus const els = [rect('r1', 100, 50, 20, 20)]; const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; const hit = boxSelectElements(els, box, 'window'); expect(hit).not.toContain('r1'); }); it('Line mit beiden Endpunkten in Box wird selektiert', () => { const els = [line('l1', 10, 10, 80, 80)]; const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; expect(boxSelectElements(els, box, 'window')).toContain('l1'); }); }); // ─── boxSelectElements: crossing (Beruehrung reicht) ───────── describe('CAD-3: crossing-Selektion (Beruehrung reicht)', () => { it('Element, das die Box nur teilweise durchstoesst, WIRD selektiert', () => { const els = [rect('r1', 100, 50, 20, 20)]; // 90..110 const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; const hit = boxSelectElements(els, box, 'crossing'); expect(hit).toContain('r1'); }); it('Element ausserhalb der Box wird nicht selektiert', () => { const els = [rect('r1', 500, 500, 20, 20)]; const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; expect(boxSelectElements(els, box, 'crossing')).not.toContain('r1'); }); it('Element, das die Box nur beruehrt (Kante = Kante), wird selektiert', () => { // rect endet exakt bei x=100 (Kante = Box-Kante) const els = [rect('r1', 95, 50, 10, 20)]; // 90..100 const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; expect(boxSelectElements(els, box, 'crossing')).toContain('r1'); }); }); // ─── Tool-Integration: Box-Drag im selectTool ──────────────── describe('CAD-3: selectTool Box-Drag', () => { it('Leerer down + move + up fuehrt Box-Selektion aus (window: links nach rechts)', () => { const inside = rect('r1', 50, 50, 20, 20); const outside = rect('r2', 500, 500, 20, 20); const { ctx, selectedIds, previews } = mkCtx([inside, outside]); // down auf Leerraum (kein Element getroffen) selectTool.handlers.down!(pe(0, 0), ctx); // move zieht Box auf selectTool.handlers.move!(pe(100, 100), ctx); expect(previews.length).toBeGreaterThanOrEqual(1); // Box-Preview // up schliesst ab selectTool.handlers.up!(pe(100, 100), ctx); expect(selectedIds.has('r1')).toBe(true); expect(selectedIds.has('r2')).toBe(false); }); it('Klick OHNE Zug (winzige Box) leert die Selektion (Legacy-Verhalten bleibt)', () => { const inside = rect('r1', 50, 50, 20, 20); const { ctx, selectedIds } = mkCtx([inside]); selectedIds.add('r1'); // down + up am selben Punkt = kein Zug selectTool.handlers.down!(pe(300, 300), ctx); selectTool.handlers.up!(pe(300, 300), ctx); expect(Array.from(selectedIds)).toHaveLength(0); }); it('Shift + Box-Select addiert zur bestehenden Selektion', () => { const a = rect('a', 50, 50, 20, 20); const b = rect('b', 200, 50, 20, 20); const { ctx, selectedIds } = mkCtx([a, b]); selectedIds.add('a'); // schon selektiert // Shift-Box um b (Shift an down UND up, wie der echte Dispatcher) selectTool.handlers.down!(pe(150, 0, { shift: true }), ctx); selectTool.handlers.up!(pe(250, 100, { shift: true }), ctx); expect(selectedIds.has('a')).toBe(true); expect(selectedIds.has('b')).toBe(true); }); it('Status meldet Anzahl und Modus', () => { const els = [rect('r1', 50, 50, 20, 20), rect('r2', 60, 60, 20, 20)]; const { ctx, statuses } = mkCtx(els); selectTool.handlers.down!(pe(0, 0), ctx); selectTool.handlers.up!(pe(100, 100), ctx); const last = statuses[statuses.length - 1]; expect(last).toContain('2'); }); it('Box-Drag startet NICHT wenn ein Element getroffen wurde', () => { const el = rect('r1', 50, 50, 20, 20); const { ctx, selectedIds } = mkCtx([el]); // down AUF das Element: normale Einzelselektion, kein Box-Start selectTool.handlers.down!(pe(50, 50), ctx); expect(selectedIds.has('r1')).toBe(true); }); });