315 lines
13 KiB
TypeScript
315 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, unknown> = {}) {
|
||
|
|
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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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');
|
||
|
|
});
|
||
|
|
});
|