/** * Pilot V2-Tools Tests (Task A3): line + select. * line: Handler-Sequenz gegen echtes CADDocument (In-Memory), Undo-Verhalten. * select: Bridge-basierte Auswahl mit Shift/Ctrl. */ import { describe, it, expect, vi } from 'vitest'; import { lineTool, coreDrawingPlugin } from '../src/plugins/builtin/core-drawing'; import { selectTool, coreModifyPlugin } from '../src/plugins/builtin/core-modify'; import { coreAnnotatePlugin } from '../src/plugins/builtin/core-annotate'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import type { ToolPointerEvent, FullToolContext } from '../src/plugins/types'; import type { CADElement } from '../src/types/cad.types'; function pe(x: number, y: number, mods: Partial> = {}): ToolPointerEvent { return { world: { x, y }, screen: { x: x / 2, y: y / 2 }, button: 0, shift: mods.shift ?? false, alt: false, ctrl: mods.ctrl ?? false, }; } function makeCtx(overrides: Partial = {}): FullToolContext & { previews: (CADElement | null)[]; statuses: string[] } { const previews: (CADElement | null)[] = []; const statuses: string[] = []; return { options: {}, setStatus: (m) => statuses.push(m), setPreview: (el) => previews.push(el), previews, statuses, ...overrides, } as FullToolContext & { previews: (CADElement | null)[]; statuses: string[] }; } function makeEl(id: string): CADElement { return { id, type: 'rect', layerId: 'l', x: 0, y: 0, width: 1, height: 1, properties: {} } as unknown as CADElement; } describe('A3 pilot: line tool', () => { it('erzeugt EINE Linie per down→up; ein undo entfernt sie', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); lineTool.handlers.down!(pe(0, 0), ctx); lineTool.handlers.move!(pe(100, 50), ctx); expect(ctx.previews.length).toBe(1); // Live-Vorschau war da lineTool.handlers.up!(pe(100, 50), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('line'); expect(all[0].properties.x1).toBe(0); expect(all[0].properties.y2).toBe(50); // Kern-Akzeptanz aus ROADMAP: Ein Werkzeug-Klick = ein Undo-Schritt expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); it('cancel verwirft die laufende Linie ohne Element', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); lineTool.handlers.down!(pe(5, 5), ctx); lineTool.handlers.cancel!(ctx); lineTool.handlers.up!(pe(9, 9), ctx); // up nach cancel: kein Start mehr expect(doc.getAllElements().length).toBe(0); }); it('Plugin-Manifest ist registrierbar und tagged basic', () => { expect(coreDrawingPlugin.manifest.enabledByDefault).toBe(true); const line = coreDrawingPlugin.tools?.find((t) => t.manifest.id === 'line'); expect(line?.manifest.tags).toContain('basic'); }); }); describe('A4.3 pilot: circle tool', () => { it('erzeugt Kreis Zentrum+Radius; ein undo entfernt ihn', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const circle = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'circle')!; circle.handlers.down!(pe(100, 100), ctx); circle.handlers.move!(pe(200, 100), ctx); expect(ctx.previews.length).toBe(1); circle.handlers.up!(pe(200, 100), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('circle'); expect(all[0].x).toBe(100); expect(all[0].properties.radius).toBe(100); // Distanz wie Legacy expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.3 pilot: arc tool', () => { it('klick-progressiv: Zentrum→Start→Ende committet beim 3. Klick mit Grad-Winkeln', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!; // Klick 1: Zentrum (up gehört zum Klick, committet NICHT — klick-progressiv) arc.handlers.down!(pe(0, 0), ctx); expect(doc.getAllElements().length).toBe(0); // Phase 1/2 committet nie // Klick 2: Startpunkt auf Radius 50 nach Osten (0°) arc.handlers.down!(pe(50, 0), ctx); expect(doc.getAllElements().length).toBe(0); // Klick 3: Endpunkt oben (90°) → JETZT committet der dritte down arc.handlers.down!(pe(0, 50), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('arc'); expect(all[0].x).toBe(0); expect(all[0].y).toBe(0); expect(all[0].properties.radius).toBe(50); expect(all[0].properties.startAngle).toBeCloseTo(0); expect(all[0].properties.endAngle).toBeCloseTo(90); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); it('cancel bricht mehrphasige Sitzung vollstaendig ab', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const arc = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'arc')!; arc.handlers.down!(pe(0, 0), ctx); // phase 1 arc.handlers.down!(pe(30, 0), ctx); // phase 2 arc.handlers.cancel!(ctx); // reset arc.handlers.down!(pe(99, 99), ctx); // startet neu bei phase 1 // Kein Commit erfolgt (nur phase 1), nichts im Doc: expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.3 pilot: polyline/polygon tools', () => { it('polyline sammelt Punkte und committet per ENTER ab 2', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const pl = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polyline')!; pl.handlers.down!(pe(0, 0), ctx); pl.handlers.move!(pe(10, 10), ctx); expect(ctx.previews.length).toBeGreaterThan(0); pl.handlers.down!(pe(20, 0), ctx); pl.handlers.down!(pe(30, 30), ctx); const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; const consumed = pl.handlers.key!(kev, ctx); expect(consumed).toBe(true); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('polyline'); expect((all[0].properties.points as Pt[]).length).toBe(3); expect(all[0].x).toBe(15); // BBox-Zentrum min0 max30 expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); it('polygon braucht mind. 3 Punkte; ENTER davor meldet Hinweis', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const pg = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'polygon')!; const ctx = makeCtx({ doc }); pg.handlers.down!(pe(0, 0), ctx); pg.handlers.down!(pe(10, 10), ctx); const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; pg.handlers.key!(kev, ctx); // nur 2 Punkte → abgelehnt expect(doc.getAllElements().length).toBe(0); expect(ctx.statuses.some((s) => s.includes('mindestens 3'))).toBe(true); pg.handlers.down!(pe(20, -5), ctx); pg.handlers.key!(kev, ctx); // jetzt 3 Punkte → ok const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('polygon'); expect((all[0].properties.points as Pt[]).length).toBe(3); }); }); describe('A4.4 pilot: annotate tools', () => { it('dimension: down→up committet mit x1..y2-Properties; undo entfernt', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const dim = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'dimension')!; dim.handlers.down!(pe(0, 0), ctx); dim.handlers.move!(pe(60, 0), ctx); // Preview war da expect(ctx.previews.length).toBe(1); dim.handlers.up!(pe(60, 0), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('dimension'); expect(all[0].x).toBe(30); // Mitte expect(all[0].properties.x2).toBe(60); expect(all[0].width).toBeCloseTo(60); // dist als Breite wie Legacy expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); it('leader: übernimmt Text/fontSize-Konvention aus Legacy', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const leader = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'leader')!; leader.handlers.down!(pe(10, 20), ctx); leader.handlers.up!(pe(80, 90), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('leader'); expect(all[0].properties.x1).toBe(10); expect(all[0].properties.y2).toBe(90); expect(all[0].properties.text).toBe(''); expect(all[0].properties.fontSize).toBe(12); }); it('text: Einzelklick platziert leeres Textelement mit fontSize-Option', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc, options: { fontSize: 18 } }); const txt = coreAnnotatePlugin.tools!.find((t) => t.manifest.id === 'text')!; txt.handlers.down!(pe(40, 70), ctx); // kein up nötig — single-point tool const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('text'); expect(all[0].x).toBe(40); expect(all[0].y).toBe(70); expect(all[0].width).toBe(100); // Legacy-Default-BBox expect(all[0].properties.fontSize).toBe(18); // aus options statt Default 12 expect(all[0].properties.text).toBe(''); }); }); describe('A3 pilot: select tool', () => { function makeBridge(elements: CADElement[]) { let ids: string[] = []; return { bridge: { getIds: () => ids, setIds: (next: string[]) => { ids = next; }, hitTest: (wx: number, wy: number, tol: number): CADElement | null => elements.find((el) => Math.abs(el.x - wx) <= tol && Math.abs(el.y - wy) <= tol) ?? null, }, getIds: () => ids, }; } it('wählt getroffenes Element ohne Modifier exklusiv', () => { const els = [makeEl('a'), makeEl('b')]; els[0].x = 10; els[0].y = 10; els[1].x = 500; els[1].y = 500; const { bridge, getIds } = makeBridge(els); const ctx = makeCtx({ selection: bridge }); selectTool.handlers.down!(pe(10, 10), ctx); expect(getIds()).toEqual(['a']); // Klick auf b ohne Shift ersetzt Selektion selectTool.handlers.down!(pe(500, 500), ctx); expect(getIds()).toEqual(['b']); }); it('Shift addiert, Ctrl subtrahiert', () => { const els = [makeEl('a'), makeEl('b'), makeEl('c')]; els[0].x = 10; els[0].y = 10; els[1].x = 50; els[1].y = 50; els[2].x = 90; els[2].y = 90; const { bridge, getIds } = makeBridge(els); const ctx = makeCtx({ selection: bridge }); selectTool.handlers.down!(pe(10, 10), ctx); expect(getIds()).toEqual(['a']); selectTool.handlers.down!(pe(50, 50, { shift: true }), ctx); expect(getIds().sort()).toEqual(['a', 'b']); selectTool.handlers.down!(pe(90, 90, { shift: true }), ctx); expect(getIds().sort()).toEqual(['a', 'b', 'c']); selectTool.handlers.down!(pe(50, 50, { ctrl: true }), ctx); expect(getIds().sort()).toEqual(['a', 'c']); }); it('Leerklick ohne Modifier leert die Selektion; Plugin tagged basic', () => { const els = [makeEl('a')]; els[0].x = 10; els[0].y = 10; const { bridge, getIds } = makeBridge(els); const ctx = makeCtx({ selection: bridge }); selectTool.handlers.down!(pe(10, 10), ctx); expect(getIds()).toEqual(['a']); selectTool.handlers.down!(pe(999, 999), ctx); expect(getIds()).toEqual([]); expect(coreModifyPlugin.manifest.enabledByDefault).toBe(true); const sel = coreModifyPlugin.tools?.find((t) => t.manifest.id === 'select'); expect(sel?.manifest.tags).toContain('basic'); }); });