Files
web-cad/frontend/tests/pilotTools.test.ts
T

993 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Pick<ToolPointerEvent, 'shift' | 'ctrl'>> = {}): 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> = {}): 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')!;
// E1: Drag-Interaktion (down + up) mit Distanz 200×100, spacing 50
block.handlers.down!(pe(0, 0), ctx);
block.handlers.up!(pe(200, 100), ctx); // 4 cols × 2 rows = 8 Stühle
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).toBe(2); // Drag 100mm bei spacing 50 → 2 Reihen
// 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')!;
// E1: Drag-Interaktion (down + up statt nur down)
row.handlers.down!(pe(0, 0), ctx);
row.handlers.up!(pe(200, 0), ctx); // Länge 200, spacing 50 → 4 Stühle
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);
});
});
describe('C3fin interaktiv: handle-drag on select tool', () => {
function makeRect(id: string, x: number, y: number, w: number, h: number): CADElement {
return {
id, type: 'rect', layerId: 'l',
x: x + w / 2, y: y + h / 2,
width: w, height: h,
properties: { fill: '#abc' },
} as unknown as CADElement;
}
it('down auf Handle startet Drag, move skaliert, up committet', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
// Rect: (0,0) bis (50,50)
doc.addElement(makeRect('r1', 0, 0, 50, 50));
let selIds: string[] = ['r1'];
const bridge = {
getIds: () => selIds,
setIds: (ids: string[]) => { selIds = ids; },
hitTest: () => null,
};
const ctx = makeCtx({ doc, selection: bridge });
const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!;
// down auf Handle der oberen linken Ecke (0,0):
select.handlers.down!(pe(0, 0), ctx);
// move zu neuem Punkt (-20, -20):
select.handlers.move!(pe(-20, -20), ctx);
// up → Commit (Skalierung auf neue BBox von (-20,-20) bis (50,50)):
select.handlers.up!(pe(-20, -20), ctx);
const updated = doc.getElement('r1')!;
// Neue BBox: 70×70 statt 50×50:
expect(updated.width).toBe(70);
expect(updated.height).toBe(70);
expect(doc.undo()).toBe(true); // Skalierung ist undo-bar
expect(doc.getElement('r1')!.width).toBe(50);
});
it('down auf NICHT-Handle startet normale Selektion (kein Drag)', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
doc.addElement(makeRect('r2', 0, 0, 50, 50));
doc.addElement(makeRect('r3', 200, 200, 30, 30));
let selIds: string[] = ['r2'];
const bridge = {
getIds: () => selIds,
setIds: (ids: string[]) => { selIds = ids; },
// Klick bei (210,210) trifft r3:
hitTest: () => doc.getElement('r3')!,
};
const ctx = makeCtx({ doc, selection: bridge });
const select = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'select')!;
// Klick weit weg von Handles von r2 (r2-Ecken sind 0/50):
select.handlers.down!(pe(210, 210), ctx);
// Normal-Selektion: r3 ist jetzt alleine selektiert (kein Drag gestartet):
expect(selIds).toEqual(['r3']);
});
});
describe('E1: seating-row drag', () => {
it('berechnet count aus Laenge und committet in EINER Transaktion', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const ctx = makeCtx({ doc, options: { spacing: 50 } });
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
// Start bei (0,0), Ende bei (300,0): Länge 300, spacing 50 → 6 Stühle
row.handlers.down!(pe(0, 0), ctx);
row.handlers.move!(pe(150, 0), ctx); // Live-Preview
expect(ctx.previews.length).toBe(1);
row.handlers.up!(pe(300, 0), ctx);
const all = doc.getAllElements();
expect(all.length).toBe(6);
expect(all.every((e) => e.type === 'chair')).toBe(true);
// Eine rowId für alle:
const rowIds = new Set(all.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(1);
// EIN Undo entfernt die ganze Reihe:
expect(doc.undo()).toBe(true);
expect(doc.getAllElements().length).toBe(0);
});
it('cancel verwirft die laufende 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);
row.handlers.cancel!(ctx);
row.handlers.up!(pe(100, 0), ctx); // nach cancel: kein dragStart
expect(doc.getAllElements().length).toBe(0);
});
});
describe('E1: seating-block drag', () => {
it('berechnet rows×cols aus Distanz und committet Multi-Element-Set', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const ctx = makeCtx({ doc, options: { colSpacing: 50, rowSpacing: 50 } });
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
// Start (0,0), Ende (150,100): w=150/50=3 cols, h=100/50=2 rows → 6 Stühle
block.handlers.down!(pe(0, 0), ctx);
block.handlers.up!(pe(150, 100), ctx);
const all = doc.getAllElements();
expect(all.length).toBe(6);
expect(all.every((e) => e.type === 'chair')).toBe(true);
// BUG-1-Regressionsschutz: rowIds pro Reihe eindeutig
const rowIds = new Set(all.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(2);
// EIN Undo entfernt den ganzen Block:
expect(doc.undo()).toBe(true);
expect(doc.getAllElements().length).toBe(0);
});
});