/** * Task CAD-5 - MLINE (Mehrfachlinie) + Block-Editor. * * MLINE: Zwei parallele Linien (Wand/Rohr) mit gap-Offset ±gap/2 * entlang der Normalen, optionale Endkappen, ENTER-Commit ab * 2 Punkten, ALLES in EINER Transaktion. * Block-Editor: openBlockForEdit (markierte Kopien), saveBlockEdit * (zurueckschreiben + aufraeumen), discard, blockEditTool-Flow. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mlineTool } from '../src/plugins/builtin/core-drawing'; import { blockEditTool, BLOCK_EDIT_MARKER } from '../src/plugins/builtin/core-modify'; import { openBlockForEdit, saveBlockEdit, discardBlockEdit, } from '../src/services/blockEditorService'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import type { CADElement, Pt, BlockDefinition } from '../src/types/cad.types'; function mkCtx(options: Record = {}) { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); const statuses: string[] = []; const previews: (CADElement | null)[] = []; const ctx: any = { doc, options, setStatus: (m: string) => statuses.push(m), setPreview: (el: CADElement | null) => previews.push(el), }; return { ctx, doc, statuses, previews }; } function pe(x: number, y: number): { world: Pt } { return { world: { x, y } } as never; } function key(k: string): { key: string } { return { key: k } as never; } const BLOCK: BlockDefinition = { id: 'blk-1', name: 'Muster', description: '', category: 'test', elements: [ { id: 'e1', type: 'rect', layerId: 'l', x: 50, y: 25, width: 100, height: 50, properties: {} }, { id: 'e2', type: 'line', layerId: 'l', x: 50, y: 50, width: 100, height: 0, properties: { x1: 0, y1: 50, x2: 100, y2: 50 } }, ], }; afterEach(() => { // Service-State-Leak verhindern: offene Edit-Sitzungen immer verwerfen // (Dummy-Doc: deleteElements ignoriert fehlende IDs still) const { ydoc: y2 } = createInMemoryDoc(); discardBlockEdit(new CADDocument(y2)); }); beforeEach(() => { mlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); blockEditTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); }); // ─── MLINE ──────────────────────────────────────────────────── describe('CAD-5: mlineTool', () => { it('horizontale Mittellinie: 2 parallele Linien mit gap/2 Abstand', () => { const { ctx, doc } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); expect(lines).toHaveLength(2); const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort(); expect(ys[0]).toBeCloseTo(-10, 5); expect(ys[1]).toBeCloseTo(10, 5); }); it('gap-Option steuert den Abstand (gap=10 → ±5)', () => { const { ctx, doc } = mkCtx({ gap: 10 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(50, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); const ys = lines.map((l) => (l.properties.points as Pt[])[0].y).sort(); expect(ys[0]).toBeCloseTo(-5, 5); expect(ys[1]).toBeCloseTo(5, 5); }); it('Endkappen-Option: 2 zusaetzliche Linien an Start und Ende', () => { const { ctx, doc } = mkCtx({ gap: 20, cap: true }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); const caps = doc.getAllElements().filter((e) => e.properties.mlineCap); expect(caps).toHaveLength(2); // Startkappe verbindet beide Linien (y=±10) bei x=0 — Zuordnung a/b // ist Vorzeichenkonvention, daher reihenfolge-agnostisch pruefen: const startCap = caps.find((c) => (c.properties.x1 as number) === 0); expect(startCap).toBeDefined(); const ys = [startCap!.properties.y1, startCap!.properties.y2].sort((p, q) => (p as number) - (q as number)) as number[]; expect(ys[0]).toBeCloseTo(-10, 5); expect(ys[1]).toBeCloseTo(10, 5); }); it('ohne cap: nur 2 Linien, keine Kappen', () => { const { ctx, doc } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); expect(doc.getAllElements().filter((e) => e.properties.mlineCap)).toHaveLength(0); }); it('EINE Transaktion: 1 Undo entfernt alle MLINE-Teile', () => { const { ctx, doc } = mkCtx({ gap: 20, cap: true }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); expect(doc.getAllElements().length).toBe(4); // 2 Linien + 2 Kappen doc.undo(); expect(doc.getAllElements()).toHaveLength(0); }); it('ENTER mit weniger als 2 Punkten committet NICHT', () => { const { ctx, doc, statuses } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); expect(doc.getAllElements()).toHaveLength(0); expect(statuses[statuses.length - 1]).toContain('2'); }); it('L-foermiger Pfad: beide Linien folgen der Geometrie', () => { const { ctx, doc } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.down!(pe(100, 100), ctx); mlineTool.handlers.key!(key('Enter'), ctx); const lines = doc.getAllElements().filter((e) => e.type === 'polyline'); expect(lines).toHaveLength(2); for (const l of lines) { const pts = l.properties.points as Pt[]; expect(pts).toHaveLength(3); // gleiche Punktanzahl wie Mittellinie } }); it('Metadaten: mline:true und gap in properties', () => { const { ctx, doc } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.down!(pe(100, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); const line = doc.getAllElements().find((e) => e.properties.mline)!; expect(line.properties.mline).toBe(true); expect(line.properties.gap).toBe(20); }); it('cancel resettet die Sitzung', () => { const { ctx, doc } = mkCtx({ gap: 20 }); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.cancel?.(ctx); mlineTool.handlers.down!(pe(0, 0), ctx); mlineTool.handlers.key!(key('Enter'), ctx); // nur 1 Punkt der NEUEN Sitzung expect(doc.getAllElements()).toHaveLength(0); }); it('Manifest: id=mline, Optionen gap und cap', () => { expect(mlineTool.manifest.id).toBe('mline'); const keys = (mlineTool.optionsSchema ?? []).map((f) => f.key); expect(keys).toContain('gap'); expect(keys).toContain('cap'); }); }); // ─── Block-Editor Service ────────────────────────────────────── describe('CAD-5: blockEditorService', () => { function mkDoc(): CADDocument { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addBlock(BLOCK); return doc; } it('openBlockForEdit erzeugt markierte Kopien im Dokument', () => { const doc = mkDoc(); expect(openBlockForEdit(doc, 'blk-1')).toBe(true); const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); expect(copies).toHaveLength(2); expect(doc.getBlock('blk-1')!.elements).toHaveLength(2); // Original unangetastet }); it('openBlockForEdit mit unbekannter ID: false', () => { const doc = mkDoc(); expect(openBlockForEdit(doc, 'gibts-nicht')).toBe(false); }); it('saveBlockEdit schreibt bearbeitete Elemente zurueck und raeumt auf', () => { const doc = mkDoc(); openBlockForEdit(doc, 'blk-1'); // Eine Kopie bearbeiten: rect verbreitern const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); const rectCopy = copies.find((c) => c.type === 'rect')!; doc.updateElement(rectCopy.id, { width: 200 }); const saved = saveBlockEdit(doc); expect(saved).toBe('blk-1'); // Blockdefinition hat die neue Breite const savedRect = doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!; expect(savedRect.width).toBe(200); // Edit-Kopien sind aus dem Dokument entfernt const remaining = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); expect(remaining).toHaveLength(0); }); it('saveBlockEdit ohne offene Sitzung: null', () => { const doc = mkDoc(); expect(saveBlockEdit(doc)).toBeNull(); }); it('discardBlockEdit verwirft Aenderungen (Block unveraendert, Kopien weg)', () => { const doc = mkDoc(); openBlockForEdit(doc, 'blk-1'); const copies = doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER]); doc.updateElement(copies[0].id, { width: 999 }); expect(discardBlockEdit(doc)).toBe(true); expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100); expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); }); it('saveBlockEdit entfernt Marker aus den gespeicherten Elementen', () => { const doc = mkDoc(); openBlockForEdit(doc, 'blk-1'); saveBlockEdit(doc); for (const el of doc.getBlock('blk-1')!.elements) { expect((el.properties as Record)[BLOCK_EDIT_MARKER]).toBeUndefined(); } }); }); // ─── blockEditTool ───────────────────────────────────────────── describe('CAD-5: blockEditTool', () => { function mkToolCtx() { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addBlock(BLOCK); doc.addElement({ id: 'inst-1', type: 'block_instance', layerId: 'l', x: 0, y: 0, width: 0, height: 0, properties: { blockId: 'blk-1' }, } as unknown as CADElement); const statuses: string[] = []; const ctx: any = { doc, options: {}, setStatus: (m: string) => statuses.push(m), setPreview: (_el: CADElement | null) => {}, selection: { getIds: () => [] as string[], setIds: (_ids: string[]) => {}, hitTest: (_x: number, _y: number, _tol: number) => doc.getElement('inst-1'), }, }; return { ctx, doc, statuses }; } it('erster Klick auf Instanz oeffnet den Block-Editor (Kopien erscheinen)', () => { const { ctx, doc, statuses } = mkToolCtx(); blockEditTool.handlers.down!(pe(0, 0), ctx); expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(2); expect(statuses[statuses.length - 1]).toContain('Block'); }); it('zweiter Klick speichert (Block aktualisiert, Kopien weg)', () => { const { ctx, doc } = mkToolCtx(); blockEditTool.handlers.down!(pe(0, 0), ctx); // Kopie bearbeiten const copy = doc.getAllElements().find((e) => (e.properties as Record)[BLOCK_EDIT_MARKER] && e.type === 'rect')!; doc.updateElement(copy.id, { width: 150 }); blockEditTool.handlers.down!(pe(0, 0), ctx); // speichern expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(150); expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); }); it('cancel verwirft die Bearbeitung', () => { const { ctx, doc } = mkToolCtx(); blockEditTool.handlers.down!(pe(0, 0), ctx); const copy = doc.getAllElements().find((e) => (e.properties as Record)[BLOCK_EDIT_MARKER] && e.type === 'rect')!; doc.updateElement(copy.id, { width: 999 }); blockEditTool.handlers.cancel?.(ctx); expect(doc.getBlock('blk-1')!.elements.find((e) => e.type === 'rect')!.width).toBe(100); expect(doc.getAllElements().filter((e) => (e.properties as Record)[BLOCK_EDIT_MARKER])).toHaveLength(0); }); it('Klick ohne Instanz-Treffer: Status erklaert, nichts passiert', () => { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); doc.addBlock(BLOCK); const statuses: string[] = []; const ctx: any = { doc, options: {}, setStatus: (m: string) => statuses.push(m), setPreview: () => {}, selection: { getIds: () => [], setIds: () => {}, hitTest: () => null }, }; blockEditTool.handlers.down!(pe(0, 0), ctx); expect(doc.getAllElements()).toHaveLength(0); expect(statuses[statuses.length - 1]).toContain('Block-Instanz'); }); it('Manifest: id=block-edit', () => { expect(blockEditTool.manifest.id).toBe('block-edit'); }); });