/** * Task CAD-11 - Truss Professional Upgrade: * 1. chordCount (Anzahl Gurtrohre, 1-4) einstellbar * 2. Laenge als freier Zahleneintrag (nicht nur Presets) * 3. Corner mit variablem Winkel (nicht nur 90 Grad) * 4. Kreis-Traverse als eigenes Tool * 5. BOM beruecksichtigt chordCount und Kreistraversen */ import { describe, it, expect, beforeEach } from 'vitest'; import { trussTool, boxCornerTool, circleTrussTool, trussEndpoints, boxcornerEndpoints, } from '../src/plugins/builtin/event-tools'; import { buildBOM } from '../src/services/bomService'; import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; import { CADDocument } from '../src/kernel/document/CADDocument'; import { createInMemoryDoc } from './helpers/inMemoryDoc'; import type { CADElement, Pt } from '../src/types/cad.types'; function mkCtx(elements: CADElement[] = [], options: Record = {}) { const { ydoc } = createInMemoryDoc(); const doc = new CADDocument(ydoc); for (const el of elements) doc.addElement(el); 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 makeTruss(id: string, x: number, y: number, length: number, rotation: number, width = 29, chordCount = 2): CADElement { return { id, type: 'truss', layerId: 'l', x, y, width: length, height: width, properties: { length, rotation, trussWidth: width, chordCount, truss: true }, } as unknown as CADElement; } function makeCorner(id: string, x: number, y: number, armLength = 50, rotation = 0, angle = 90, width = 29): CADElement { return { id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80, properties: { armLength, rotation, angle, trussWidth: width, boxcorner: true }, } as unknown as CADElement; } function makeCircleTruss(id: string, x: number, y: number, diameter: number, width = 29, chordCount = 2): CADElement { return { id, type: 'circletruss', layerId: 'l', x, y, width: diameter, height: diameter, properties: { diameter, trussWidth: width, chordCount, circletruss: true }, } as unknown as CADElement; } beforeEach(() => { for (const t of [trussTool, boxCornerTool, circleTrussTool]) { t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); } }); // ─── 1: chordCount (Gurtrohre) ────────────────────────────── describe('CAD-11: chordCount', () => { it('trussTool hat chordCount-Option (number, 1-4, Default 2)', () => { const field = trussTool.optionsSchema?.find((f) => f.key === 'chordCount'); expect(field).toBeDefined(); expect(field!.type).toBe('number'); }); it('platzierte Traverse: chordCount=2 Standard', () => { const { ctx, doc } = mkCtx(); trussTool.handlers.down!(pe(100, 100), ctx); trussTool.handlers.up!(pe(300, 100), ctx); expect(doc.getAllElements()[0].properties.chordCount).toBe(2); }); it('chordCount=3 wird uebernommen', () => { const { ctx, doc } = mkCtx([], { chordCount: 3 }); trussTool.handlers.down!(pe(100, 100), ctx); trussTool.handlers.up!(pe(300, 100), ctx); expect(doc.getAllElements()[0].properties.chordCount).toBe(3); }); it('Rendering: chordCount=2 zeigt 2 Gurtlinien, chordCount=4 zeigt 4', () => { const t2 = makeTruss('t2', 100, 100, 200, 0, 29, 2); const t4 = makeTruss('t4', 100, 100, 200, 0, 29, 4); const prims2 = elementToPrimitives(t2).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); const prims4 = elementToPrimitives(t4).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); expect(prims2.length).toBe(2); expect(prims4.length).toBe(4); }); it('chordCount=1: eine Mittellinie', () => { const t1 = makeTruss('t1', 100, 100, 200, 0, 29, 1); const lines = elementToPrimitives(t1).filter((p) => p.kind === 'line' && p.stroke === '#4b5563'); expect(lines.length).toBe(1); const line = lines[0] as { from: Pt; to: Pt }; expect(line.from.y).toBeCloseTo(100, 0); // Mittellinie auf y=100 }); }); // ─── 2: Freie Laengeneingabe ───────────────────────────────── describe('CAD-11: Laenge als freier Zahleneintrag', () => { it('length ist number (nicht select) — Presets als separate Quick-Buttons', () => { const field = trussTool.optionsSchema?.find((f) => f.key === 'length'); expect(field).toBeDefined(); expect(field!.type).toBe('number'); }); it('Custom-Laenge 175 wird uebernommen', () => { const { ctx, doc } = mkCtx([], { length: 175 }); trussTool.handlers.down!(pe(100, 100), ctx); trussTool.handlers.up!(pe(300, 100), ctx); expect(doc.getAllElements()[0].properties.length).toBe(175); }); }); // ─── 3: Corner mit variablem Winkel ───────────────────────── describe('CAD-11: Corner variablem Winkel', () => { it('boxCornerTool hat angle-Option (number, Default 90)', () => { const field = boxCornerTool.optionsSchema?.find((f) => f.key === 'angle'); expect(field).toBeDefined(); expect(field!.type).toBe('number'); }); it('boxcornerEndpoints: 90-Grad-Ecke = Arm2 bei rot+90', () => { const c = makeCorner('c1', 100, 100, 50, 0, 90); const [a1, a2] = boxcornerEndpoints(c); expect(a1.x).toBeCloseTo(150, 0); expect(a1.y).toBeCloseTo(100, 0); expect(a2.x).toBeCloseTo(100, 0); expect(a2.y).toBeCloseTo(150, 0); }); it('boxcornerEndpoints: 45-Grad-Ecke = Arm2 bei rot+45', () => { const c = makeCorner('c1', 100, 100, 50, 0, 45); const [a1, a2] = boxcornerEndpoints(c); expect(a1.x).toBeCloseTo(150, 0); expect(a1.y).toBeCloseTo(100, 0); const halfDiag = 50 * Math.cos(Math.PI / 4); expect(a2.x).toBeCloseTo(100 + halfDiag, 0); expect(a2.y).toBeCloseTo(100 + halfDiag, 0); }); it('platzierte 30-Grad-Ecke: angle=30 in properties', () => { const { ctx, doc } = mkCtx([], { angle: 30 }); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); expect(doc.getAllElements()[0].properties.angle).toBe(30); }); it('Default-Winkel ist 90 (abwaertskompatibel)', () => { const { ctx, doc } = mkCtx(); boxCornerTool.handlers.down!(pe(100, 100), ctx); boxCornerTool.handlers.up!(pe(200, 100), ctx); expect(doc.getAllElements()[0].properties.angle).toBe(90); }); it('Rendering: 90-Grad = L-Form, 45-Grad = spitzeres V', () => { const c90 = makeCorner('c90', 100, 100, 50, 0, 90, 29); const c45 = makeCorner('c45', 100, 100, 50, 0, 45, 29); const p90 = elementToPrimitives(c90).find((p) => p.kind === 'polyline') as { points: Pt[] }; const p45 = elementToPrimitives(c45).find((p) => p.kind === 'polyline') as { points: Pt[] }; // Beide sind Polygone mit >= 6 Punkten expect(p90.points.length).toBeGreaterThanOrEqual(6); expect(p45.points.length).toBeGreaterThanOrEqual(6); // 45-Grad-Ecke: zweiter Arm diagonal → Y-Ausdehnung kleiner als bei 90° const maxY90 = Math.max(...p90.points.map((p) => p.y)); const maxY45 = Math.max(...p45.points.map((p) => p.y)); expect(maxY45).toBeLessThan(maxY90); }); }); // ─── 4: Kreis-Traverse ────────────────────────────────────── describe('CAD-11: Kreis-Traverse', () => { it('circleTrussTool existiert mit Manifest', () => { expect(circleTrussTool.manifest.id).toBe('circle-truss'); const keys = (circleTrussTool.optionsSchema ?? []).map((f) => f.key); expect(keys).toContain('diameter'); expect(keys).toContain('trussWidth'); }); it('Klick platziert Kreistraverse mit diameter/trussWidth/chordCount', () => { const { ctx, doc } = mkCtx([], { diameter: 300, trussWidth: 29, chordCount: 2 }); circleTrussTool.handlers.down!(pe(200, 200), ctx); const els = doc.getAllElements(); expect(els).toHaveLength(1); expect(els[0].type).toBe('circletruss'); expect(els[0].properties.diameter).toBe(300); expect(els[0].properties.trussWidth).toBe(29); expect(els[0].properties.chordCount).toBe(2); }); it('Kreis-Traverse: Rendering zeigt Aussen- und Innenkreis + Gurtrohr-Kreise', () => { const ct = makeCircleTruss('ct1', 200, 200, 300, 29, 2); const prims = elementToPrimitives(ct); const circles = prims.filter((p) => p.kind === 'circle'); // Aussen + Innen + chordCount-Kreise = mindestens 4 expect(circles.length).toBeGreaterThanOrEqual(4); }); it('1 Undo entfernt die Kreistraverse', () => { const { ctx, doc } = mkCtx(); circleTrussTool.handlers.down!(pe(200, 200), ctx); expect(doc.getAllElements()).toHaveLength(1); doc.undo(); expect(doc.getAllElements()).toHaveLength(0); }); it('diameter-Default ist 200', () => { const { ctx, doc } = mkCtx(); circleTrussTool.handlers.down!(pe(200, 200), ctx); expect(doc.getAllElements()[0].properties.diameter).toBe(200); }); }); // ─── 5: BOM-Integration ───────────────────────────────────── describe('CAD-11: BOM mit chordCount + Kreistraversen', () => { it('BOM gruppiert nach chordCount (2-rohr vs 4-rohr getrennt)', () => { const els = [ makeTruss('t1', 0, 0, 200, 0, 29, 2), makeTruss('t2', 0, 0, 200, 0, 29, 4), ]; const bom = buildBOM(els); expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2); }); it('BOM enthaelt Kreistraversen mit Durchmesser', () => { const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)]; const bom = buildBOM(els); expect(bom.rows).toHaveLength(1); expect(bom.rows[0].label).toContain('Kreis'); expect(bom.rows[0].label).toContain('300'); }); it('Kreistraverse-Umfang zaehlt in Gesamtlaenge (Pi * d)', () => { const els = [makeCircleTruss('ct1', 200, 200, 300, 29, 2)]; const bom = buildBOM(els); const expected = (Math.PI * 300) / 100; // ~9.42m expect(bom.totalLengthM).toBeCloseTo(expected, 1); }); });