/** * 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 { coreMeasurePlugin } from '../src/plugins/builtin/core-measure'; import { eventToolsV2Plugin } from '../src/plugins/builtin/event-tools'; 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('A4.5 pilot: revcloud tool', () => { it('committet per ENTER ab 3 Punkten mit Legacy-Konvention arcHeight=8', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const cloud = coreDrawingPlugin.tools!.find((t) => t.manifest.id === 'revcloud')!; cloud.handlers.down!(pe(0, 0), ctx); cloud.handlers.down!(pe(100, 0), ctx); cloud.handlers.down!(pe(100, 50), ctx); const kev = { key: 'Enter', preventDefault: () => {} } as unknown as KeyboardEvent; const consumed = cloud.handlers.key!(kev, ctx); expect(consumed).toBe(true); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('revcloud'); expect((all[0].properties.points as Pt[]).length).toBe(3); expect(all[0].properties.arcHeight).toBe(8); // Legacy-Konvention expect(all[0].properties.fill).toBe('none'); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.5 pilot: measure tool', () => { it('2 Klicks zeigen Distanz+Winkel im Status; erzeugt KEIN Element', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const meas = coreMeasurePlugin.tools!.find((t) => t.manifest.id === 'measure')!; meas.handlers.down!(pe(0, 0), ctx); expect(ctx.statuses.some((s) => s.includes('Endpunkt'))).toBe(true); meas.handlers.down!(pe(30, 40), ctx); // dist=50, angle≈53.13° const last = ctx.statuses[ctx.statuses.length - 1]; expect(last).toContain('distance:'); expect(last).toContain('angle:'); expect(last).toContain('53.1'); // atan2(40,30)≈53.13° // KernLegacy-Verhalten: Messung erzeugt kein Element expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.5 pilot: hatch tool', () => { it('setzt hatch-Properties via updateElement; undo macht rückgängig', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); // Seed-Rect im Doc, das beim hitTest zurückkommt: const rectEl = { id: 'r1', type: 'rect', layerId: 'l', x: 50, y: 50, width: 10, height: 10, properties: {} } as unknown as CADElement; doc.addElement(rectEl); const bridge = { getIds: () => [], setIds: () => {}, hitTest: () => rectEl, }; const ctx = makeCtx({ doc, selection: bridge, options: { hatchPattern: 'cross', hatchSpacing: 12 } }); const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!; hatch.handlers.down!(pe(50, 50), ctx); const updated = doc.getElement('r1')!; expect(updated.properties.hatch).toBe(true); expect(updated.properties.hatchPattern).toBe('cross'); // aus Options expect(updated.properties.hatchSpacing).toBe(12); expect(doc.undo()).toBe(true); // A2b-Update ist selbst Undo-Schritt expect(doc.getElement('r1')!.properties.hatch).toBeUndefined(); }); it('lehnt offene Elementtypen (line) mit Hinweis ab', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const lineEl = { id: 'l1', type: 'line', layerId: 'l', x: 0, y: 0, width: 5, height: 5, properties: { x1: 0, y1: 0, x2: 5, y2: 5 } } as unknown as CADElement; doc.addElement(lineEl); const bridge = { getIds: () => [], setIds: () => {}, hitTest: () => lineEl }; const ctx = makeCtx({ doc, selection: bridge }); const hatch = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'hatch')!; hatch.handlers.down!(pe(2, 2), ctx); expect(doc.getElement('l1')!.properties.hatch).toBeUndefined(); // nichts geändert expect(ctx.statuses.some((s) => s.includes('rect/circle/polygon'))).toBe(true); }); }); describe('A4.9 pilot: event tools', () => { it('chair platziert Einzel-Element per Klick', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const chair = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'chair')!; chair.handlers.down!(pe(100, 200), ctx); const all = doc.getAllElements(); expect(all.length).toBe(1); expect(all[0].type).toBe('chair'); expect(all[0].x).toBe(100); expect(all[0].y).toBe(200); }); it('seating-block erzeugt rows×cols Elemente in EINER Transaktion (ein undo = ganzer Block weg)', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!; block.handlers.down!(pe(0, 0), ctx); // Legacy-Default: rows=3, cols=8 const all = doc.getAllElements(); expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz expect(all.every((e) => e.type === 'chair')).toBe(true); // BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds const rowIds = new Set(all.map((e) => e.properties.rowId)); expect(rowIds.size).toBeGreaterThanOrEqual(3); // Kern-Akzeptanz: EIN undo entfernt den GANZEN Block expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); it('seating-row platziert mehrere Stühle mit gemeinsamer rowId; undo entfernt Reihe', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const ctx = makeCtx({ doc }); const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!; row.handlers.down!(pe(0, 0), ctx); const all = doc.getAllElements(); expect(all.length).toBeGreaterThanOrEqual(2); const rowIds = new Set(all.map((e) => e.properties.rowId)); expect(rowIds.size).toBe(1); // eine Reihe → EINE rowId expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.6 pilot: move/copy tools', () => { it('move verschiebt selektierte Elemente in EINER Transaktion (undo entfernt Versatz)', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addElement(makeEl('m1')); doc.addElement(makeEl('m2')); let selIds = ['m1', 'm2']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!; const origM1x = doc.getElement('m1')!.x; const origY = doc.getElement('m1')!.y; move.handlers.down!(pe(0, 0), ctx); // Basispunkt move.handlers.down!(pe(50, -20), ctx); // Zielklick → Commit expect(doc.getElement('m1')!.x).toBe(origM1x + 50); expect(doc.getElement('m2')!.y).toBe(origY - 20); // EINE Transaktion für beide → ein undo stellt beide wieder her: expect(doc.undo()).toBe(true); expect(doc.getElement('m1')!.x).toBe(origM1x); expect(doc.getElement('m2')!.y).toBe(origY); }); it('copy erzeugt neue Elemente (IDs neu) ohne Original zu veraendern', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addElement(makeEl('o1')); let selIds = ['o1']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const copy = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'copy')!; copy.handlers.down!(pe(0, 0), ctx); copy.handlers.down!(pe(100, 100), ctx); const all = doc.getAllElements(); expect(all.length).toBe(2); // Original + Kopie expect(all[0].id).toBe('o1'); // Original unberührt expect(all[0].x).not.toBe(all[1].x); // Kopie versetzt expect(all[1].id).not.toBe('o1'); // neue ID expect(all[1].type).toBe(all[0].type); // gleicher Typ expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); // nur Original bleibt }); it('Komfort: bei leerer Selektion wird Element unter Klick auto-gewählt', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const target = makeEl('auto'); target.x = 10; target.y = 10; doc.addElement(target); let selIds2: string[] = []; const bridge = { getIds: () => selIds2, setIds: (ids: string[]) => { selIds2 = ids; }, hitTest: () => target, }; const ctx = makeCtx({ doc, selection: bridge }); const move = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'move')!; move.handlers.down!(pe(10, 10), ctx); // Basis + Auto-Select expect(selIds2).toEqual(['auto']); move.handlers.down!(pe(30, 30), ctx); // Zielklick (Δ=+20) expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); }); }); describe('A4.10 pilot: geometry tools', () => { function makeLineEl(id: string): CADElement { return { id, type: 'line', layerId: 'l', x: 100, y: 0, width: 200, height: 0, properties: { x1: 0, y1: 0, x2: 200, y2: 0 }, } as unknown as CADElement; } function makeCircleEl(id: string): CADElement { return { id, type: 'circle', layerId: 'l', x: 100, y: 0, width: 80, height: 80, properties: { radius: 40 }, } as unknown as CADElement; } it('trim: Kante+Ziel kürzt die Linie am Schnittpunkt; undo stellt her', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const boundary = makeCircleEl('bnd'); const target = makeLineEl('tgt'); doc.addElement(boundary); doc.addElement(target); // Sequenzieller Brücken-HitTest: 1. Klick → boundary, 2. Klick → target const pickQueue = [boundary, target]; let pickIdx = 0; const bridge = { getIds: () => [] as string[], setIds: () => {}, hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)], }; const ctx = makeCtx({ doc, selection: bridge }); const trim = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'trim')!; trim.handlers.down!(pe(140, 0), ctx); // Kante trim.handlers.down!(pe(180, 0), ctx); // Ziel-Linie rechts vom Kreis const trimmed = doc.getElement('tgt')!; expect(trimmed.properties.x2).toBeLessThan(200); // gekürzt // Legacy-Semantik: das NÄHERE Ende wird auf den ERSTEN Schnittpunkt // gesetzt (findIntersection wählt den nächsten zu p1 → x=60): expect(Math.abs(trimmed.properties.x2 as number)).toBeCloseTo(60); // Schnitt bei x=140 expect(doc.undo()).toBe(true); expect(doc.getElement('tgt')!.properties.x2).toBe(200); }); it('extend: Linie wird zur Kante hin verlängert', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); // kurze Linie (10→50); Kreis (100,0,r=40) hat Schnitte bei x=60/140 // → Extend muss das nahe Ende x2 auf 60 strecken const boundary = makeCircleEl('bnd'); const shortLine = { id: 'sl', type: 'line', layerId: 'l', x: 30, y: 0, width: 40, height: 0, properties: { x1: 10, y1: 0, x2: 50, y2: 0 }, } as unknown as CADElement; doc.addElement(boundary); doc.addElement(shortLine); const pickQueue = [boundary, shortLine]; let pickIdx = 0; const bridge = { getIds: () => [] as string[], setIds: () => {}, hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)], }; const ctx = makeCtx({ doc, selection: bridge }); const extend = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'extend')!; extend.handlers.down!(pe(100, 40), ctx); extend.handlers.down!(pe(45, 0), ctx); const ext = doc.getElement('sl')!; // Extend richtet das nahe Ende exakt auf den Schnittpunkt aus: expect(ext.properties.x2).toBeCloseTo(60); expect(doc.undo()).toBe(true); expect(doc.getElement('sl')!.properties.x2).toBe(50); // Original-Länge }); }); describe('A4.7 pilot: transform tools', () => { it('rotate dreht um Pivot des ersten Elements; ein undo stellt zurück', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const rotEl = makeEl('r1'); doc.addElement(rotEl); let selIds: string[] = ['r1']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const rotate = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'rotate')!; // Basispunkt rechts vom Zentrum (0°-Richtung), Element x=10 rotate.handlers.down!(pe(60, 10), ctx); // Referenzklick oben → Δwinkel ≈ +90° (CCW) rotate.handlers.down!(pe(10, 60), ctx); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); }); it('scale vergrößert width/height um Faktor dist(pivot,zweiteKlick)/dist(pivot,basis)', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const sq = makeEl('s1'); sq.width = 40; sq.height = 40; sq.x = 0; sq.y = 0; doc.addElement(sq); let selIds: string[] = ['s1']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const scale = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'scale')!; // Basis bei Distanz 50 vom Pivot, Ziel bei 100 → Faktor 2 scale.handlers.down!(pe(50, 0), ctx); scale.handlers.down!(pe(100, 0), ctx); expect(doc.getElement('s1')!.width).toBeCloseTo(80); // 40×2 expect(doc.getElement('s1')!.height).toBeCloseTo(80); expect(doc.undo()).toBe(true); expect(doc.getElement('s1')!.width).toBeCloseTo(40); }); it('mirror spiegelt Element an Achse basis→referenz', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const el = makeEl('mi'); el.x = 30; el.y = 0; doc.addElement(el); let selIds: string[] = ['mi']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const mirror = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'mirror')!; // Spiegelachse = Y-Achse (basis (0,0)→ref (0,100)): Punkt (30,0)→(−30,0) mirror.handlers.down!(pe(0, 0), ctx); mirror.handlers.down!(pe(0, 100), ctx); const mirrored = doc.getElement('mi')!; expect(mirrored.x).toBeLessThan(0); // jenseits der Achse expect(Math.abs(mirrored.x)).toBeGreaterThanOrEqual(29); // ~spiegelsymmetrisch expect(doc.undo()).toBe(true); }); }); describe('A4.8 pilot: delete tool', () => { it('löscht alle selektierten Elemente in EINER Transaktion; undo stellt her', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addElement(makeEl('d1')); doc.addElement(makeEl('d2')); doc.addElement(makeEl('keep')); let selIds: string[] = ['d1', 'd2']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge }); const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!; del.handlers.down!(pe(0, 0), ctx); const remaining = doc.getAllElements().map((e) => e.id); expect(remaining).toEqual(['keep']); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(3); }); it('Direktklick ohne Selektion löscht getroffenes Element', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const el = makeEl('hitme'); el.x = 20; el.y = 20; doc.addElement(el); const bridge = { getIds: () => [] as string[], setIds: () => {}, hitTest: () => el, }; const ctx = makeCtx({ doc, selection: bridge }); const del = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'delete')!; del.handlers.down!(pe(20, 20), ctx); expect(doc.getAllElements().length).toBe(0); }); }); describe('A4.8 pilot: offset tool', () => { it('erzeugt Versatz-Kopie mit Seitenparameter; Original bleibt', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const line = { id: 'src1', type: 'line', layerId: 'l', x: 50, y: 0, width: 100, height: 0, properties: { x1: 0, y1: 0, x2: 100, y2: 0 }, } as unknown as CADElement; doc.addElement(line); let selIds: string[] = []; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => line, }; const ctx = makeCtx({ doc, selection: bridge }); const off = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'offset')!; off.handlers.down!(pe(50, 0), ctx); // Klick1: Quelle wählen (Basis) off.handlers.down!(pe(50, -30), ctx); // Klick2: Versatz nach oben (side=-1) const all = doc.getAllElements(); expect(all.length).toBe(2); // Original + Kopie expect(all[0].id).toBe('src1'); // Original unberührt expect(all[1].id.startsWith('off_')).toBe(true); // side=-1 bei Rechtslaufender Linie → nach OBEN (y kleiner): expect(all[1].properties.y1).toBe(-30); expect(all[1].properties.y2).toBe(-30); expect(all[1].properties.x1).toBe(0); // x bleibt expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); }); }); 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'); }); }); describe('D-Ext: array-rect tool', () => { it('vervielfaeltigt Selektion rows×cols in EINER Transaktion', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addElement(makeEl('a1')); let selIds: string[] = ['a1']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge, options: { rows: 2, cols: 2, dx: 60, dy: 60 } }); const arr = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'array-rect')!; arr.handlers.down!(pe(0, 0), ctx); arr.handlers.down!(pe(0, 0), ctx); expect(doc.getAllElements().length).toBe(4); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); }); }); describe('D-Ext: array-polar tool', () => { it('verteilt count Kopien gleichmaessig um das Zentrum', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const elem = makeEl('p1'); elem.x = 100; elem.y = 0; doc.addElement(elem); let selIds: string[] = ['p1']; const bridge = { getIds: () => selIds, setIds: (ids: string[]) => { selIds = ids; }, hitTest: () => null, }; const ctx = makeCtx({ doc, selection: bridge, options: { count: 4 } }); const polar = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'array-polar')!; polar.handlers.down!(pe(0, 0), ctx); polar.handlers.down!(pe(0, 0), ctx); const all = doc.getAllElements(); expect(all.length).toBe(4); expect(doc.undo()).toBe(true); expect(doc.getAllElements().length).toBe(1); }); });