From ddfeadac587e78fe5cf782ede02205ac1acab376 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 26 Aug 2026 21:23:22 +0200 Subject: [PATCH] task(A2b): CADDocument facade with transactions and undo --- frontend/src/kernel/document/CADDocument.ts | 137 ++++++++++++++++++++ frontend/tests/cadDocument.test.ts | 121 +++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 frontend/src/kernel/document/CADDocument.ts create mode 100644 frontend/tests/cadDocument.test.ts diff --git a/frontend/src/kernel/document/CADDocument.ts b/frontend/src/kernel/document/CADDocument.ts new file mode 100644 index 0000000..87964fa --- /dev/null +++ b/frontend/src/kernel/document/CADDocument.ts @@ -0,0 +1,137 @@ +/** + * CADDocument – Fassade über YjsDocument (Task A2b). + * + * - Elementar-CRUD auf CADElement/CADLayer + * - `transact(fn)`: Bündelt Änderungen zu EINEM Undo-Schritt + * (kollaborativ korrekt via Y.UndoManager) + * - `undo()/redo()` wrappen den Manager; HistoryPanel liest später daraus (A4) + * - Keine Tool-Logik! Nur Dokument-Operationen. + * + * Nutzung künftiger V2-Werkzeuge (ab A3): + * doc.transact(() => doc.addElement(line)); // 1 Klick = 1 Undo-Schritt + */ +import * as Y from 'yjs'; +import { YjsDocument } from '../../crdt/YjsDocument'; +import type { CADElement, CADLayer } from '../../types/cad.types'; + +export class CADDocument { + private readonly ydoc: YjsDocument; + private readonly undoManager: Y.UndoManager; + + /** @param ydoc Netzwerkfrei erstellbar (createInMemoryDoc) oder CRDT-verbunden. */ + constructor(ydoc: YjsDocument) { + this.ydoc = ydoc; + // Trackt alle Operationen auf Elements + Layers → gemeinsame Undo-Historie. + // Blocks/Groups/Configs bewusst NICHT tracken: Werkzeuge ändern primär Elemente/Layer. + // captureTimeout 0: KEIN automatisches Mergen zeitnaher Transaktionen — + // jede explizite transact() bleibt EIGENER Undo-Schritt (deterministisch für Tools). + this.undoManager = new Y.UndoManager([this.ydoc.data.elements, this.ydoc.data.layers], { + captureTimeout: 0, + }); + } + + // ── Lesen ──────────────────────────────────────────────── + + /** Ein Element per ID (oder undefined). */ + getElement(id: string): CADElement | undefined { + return this.ydoc.data.elements.get(id); + } + + /** Alle Elemente als Array. */ + getAllElements(): CADElement[] { + return Array.from(this.ydoc.data.elements.values()); + } + + /** Ein Layer per ID (oder undefined). */ + getLayer(id: string): CADLayer | undefined { + return this.ydoc.data.layers.get(id); + } + + /** Alle Layer als Array. */ + getAllLayers(): CADLayer[] { + return Array.from(this.ydoc.data.layers.values()); + } + + // ── Schreiben ──────────────────────────────────────────── + + /** Fügt ein Element hinzu (einzelner Aufruf = eigener Undo-Schritt). */ + addElement(el: CADElement): void { + this.transact(() => { + this.ydoc.data.elements.set(el.id, el); + }); + } + + /** + * Aktualisiert ein Element: Top-Level-Felder werden ersetzt, + * `patch.properties` wird SHALLOW in bestehende Properties gemergt + * (Details wie fill/rotation einzeln änderbar, ohne den Rest zu verlieren). + * @throws wenn Element nicht existiert + */ + updateElement(id: string, patch: Partial): void { + const existing = this.ydoc.data.elements.get(id); + if (!existing) throw new Error(`updateElement: Element '${id}' nicht gefunden`); + const { properties, ...rest } = patch; + const merged: CADElement = { + ...existing, + ...rest, + properties: properties ? { ...existing.properties, ...properties } : existing.properties, + }; + this.transact(() => { + this.ydoc.data.elements.set(id, merged); + }); + } + + /** Entfernt mehrere Elemente in EINER Transaktion. Ignoriert unbekannte IDs still. */ + deleteElements(ids: string[]): void { + this.transact(() => { + for (const id of ids) { + if (this.ydoc.data.elements.has(id)) { + this.ydoc.data.elements.delete(id); + } + } + }); + } + + // ── Transaktion & Undo ─────────────────────────────────── + + /** Führt fn aus; ALLE darin getätigten Y-Änderungen werden EIN Undo-Schritt. */ + transact(fn: () => void): void { + this.ydoc.doc.transact(fn); + } + + /** Macht den letzten Schritt rückgängig. false wenn nichts da. */ + undo(): boolean { + if (!this.canUndo()) return false; + this.undoManager.undo(); + return true; + } + + /** Stellt einen Rückgängig-Schritt wieder her. false wenn nichts da. */ + redo(): boolean { + if (!this.canRedo()) return false; + this.undoManager.redo(); + return true; + } + + /** Gibt es etwas zum Rückgängigmachen? */ + canUndo(): boolean { + return this.undoManager.undoStack.length > 0; + } + + /** Gibt es etwas zum Wiederherstellen? */ + canRedo(): boolean { + return this.undoManager.redoStack.length > 0; + } + + /** Exponiert den Manager (HistoryPanel liest Stacklängen für die UI, A4). */ + getUndoManager(): Y.UndoManager { + return this.undoManager; + } + + // ── Subscriptions ──────────────────────────────────────── + + /** Benachrichtigung bei JEDEM Doc-Update (inkl. Remote über CRDT). */ + onChanged(cb: () => void): () => void { + return this.ydoc.onChange(cb); + } +} diff --git a/frontend/tests/cadDocument.test.ts b/frontend/tests/cadDocument.test.ts new file mode 100644 index 0000000..cc7fd71 --- /dev/null +++ b/frontend/tests/cadDocument.test.ts @@ -0,0 +1,121 @@ +/** + * CADDocument Tests (Task A2b). + * Kern-Verhalten: CRUD, Shallow-Merge, Transaktion = 1 Undo-Schritt, + * undo/redo-Stacks, deleteElements selektiv. + */ +import { describe, it, expect } from 'vitest'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import type { CADElement } from '../src/types/cad.types'; + +function makeEl(id: string, props: Record = {}): CADElement { + return { + id, + type: 'rect', + layerId: 'layer-1', + x: 10, + y: 20, + width: 30, + height: 40, + properties: props, + } as unknown as CADElement; +} + +function makeDoc(): { doc: CADDocument } { + const { ydoc } = createInMemoryDoc(); + return { doc: new CADDocument(ydoc) }; +} + +describe('CADDocument', () => { + it('a) addElement → getAllElements enthaelt es', () => { + const { doc } = makeDoc(); + doc.addElement(makeEl('e1')); + const all = doc.getAllElements(); + expect(all.length).toBe(1); + expect(all[0].id).toBe('e1'); + expect(doc.getElement('e1')).toBeDefined(); + expect(doc.getElement('missing')).toBeUndefined(); + }); + + it('b) transact um zwei addElements = EIN Undo-Schritt entfernt BEIDE', () => { + const { doc } = makeDoc(); + doc.transact(() => { + doc.addElement(makeEl('a')); + doc.addElement(makeEl('b')); + }); + expect(doc.getAllElements().length).toBe(2); + expect(doc.canUndo()).toBe(true); + + expect(doc.undo()).toBe(true); + expect(doc.getAllElements().length).toBe(0); // beide weg mit einem undo! + expect(doc.canUndo()).toBe(false); + }); + + it('c) redo stellt beide Elemente wieder her', () => { + const { doc } = makeDoc(); + doc.transact(() => { + doc.addElement(makeEl('a')); + doc.addElement(makeEl('b')); + }); + doc.undo(); + expect(doc.getAllElements().length).toBe(0); + + expect(doc.canRedo()).toBe(true); + expect(doc.redo()).toBe(true); + expect(doc.getAllElements().length).toBe(2); + expect(doc.getElement('a')).toBeDefined(); + expect(doc.getElement('b')).toBeDefined(); + }); + + it('d) updateElement merged properties SHALLOW (bestehende Keys bleiben)', () => { + const { doc } = makeDoc(); + doc.addElement(makeEl('m', { fill: 'red', rotation: 45, stroke: 'black' })); + + doc.updateElement('m', { properties: { fill: 'blue' } }); + const el = doc.getElement('m')!; + expect(el.properties.fill).toBe('blue'); // ueberschrieben + expect(el.properties.rotation).toBe(45); // erhalten + expect(el.properties.stroke).toBe('black'); // erhalten + }); + + it('d2) updateElement kann Top-Level-Felder setzen und wirft bei fehlender ID', () => { + const { doc } = makeDoc(); + doc.addElement(makeEl('m')); + doc.updateElement('m', { x: 999 }); + expect(doc.getElement('m')!.x).toBe(999); + expect(() => doc.updateElement('ghost', { x: 1 })).toThrow(/nicht gefunden/); + }); + + it('e) deleteElements entfernt nur die genannten IDs', () => { + const { doc } = makeDoc(); + doc.transact(() => { + doc.addElement(makeEl('keep1')); + doc.addElement(makeEl('del1')); + doc.addElement(makeEl('del2')); + doc.addElement(makeEl('keep2')); + }); + + doc.deleteElements(['del1', 'del2', 'unbekannt']); // unbekannte still ignorieren + const ids = doc.getAllElements().map((e) => e.id).sort(); + expect(ids).toEqual(['keep1', 'keep2']); + }); + + it('undo nach deleteElements stellt geloeschte Elemente wieder her', () => { + const { doc } = makeDoc(); + doc.addElement(makeEl('x1')); + doc.addElement(makeEl('x2')); + + doc.deleteElements(['x1']); + expect(doc.getAllElements().length).toBe(1); + + doc.undo(); // Delete rueckgaengig + expect(doc.getAllElements().length).toBe(2); + expect(doc.getElement('x1')).toBeDefined(); + }); + + it('undo/redo liefern false auf leeren Stacks', () => { + const { doc } = makeDoc(); + expect(doc.undo()).toBe(false); + expect(doc.redo()).toBe(false); + }); +});