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

419 lines
16 KiB
TypeScript

/**
* 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 { coreMeasurePlugin } from '../src/plugins/builtin/core-measure';
import { coreAnnotatePlugin } from '../src/plugins/builtin/core-annotate';
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('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');
});
});