/** * Task CAD-9 - Stueckliste (BOM) fuer Traversen. * * buildBOM: gruppiert Traversen + Boxcorner nach Typ + Mass * (Laenge x Breite), zaehlt Stueckzahlen, summiert Gesamt-meter. * exportBOMCsv: saubere CSV mit Kopfzeile fuer Excel-Import. * Dialog zeigt Tabelle + CSV-Download. */ import { describe, it, expect } from 'vitest'; import { buildBOM, exportBOMCsv, type BOMRow } from '../src/services/bomService'; import type { CADElement } from '../src/types/cad.types'; function truss(id: string, x: number, y: number, length: number, rotation: number, width = 29): CADElement { return { id, type: 'truss', layerId: 'l', x, y, width: length, height: width, properties: { length, rotation, trussWidth: width, truss: true }, } as unknown as CADElement; } function corner(id: string, x: number, y: number, armLength: number, rotation: number, width = 29): CADElement { return { id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80, properties: { armLength, rotation, trussWidth: width, boxcorner: true }, } as unknown as CADElement; } function chair(id: string): CADElement { return { id, type: 'chair', layerId: 'l', x: 0, y: 0, width: 45, height: 45, properties: {}, } as unknown as CADElement; } // ─── buildBOM: Gruppierung ────────────────────────────────── describe('CAD-9: buildBOM (Gruppierung + Zaehlung)', () => { it('zwei identische Traversen (200x29) ergeben EINE Position mit Anzahl 2', () => { const els = [ truss('t1', 0, 0, 200, 0), truss('t2', 100, 100, 200, 90), ]; const bom = buildBOM(els); const rows = bom.rows.filter((r) => r.type === 'Traverse'); expect(rows).toHaveLength(1); expect(rows[0].count).toBe(2); }); it('unterschiedliche Laengen ergetrennte Positionen', () => { const els = [ truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 100, 0), truss('t3', 0, 0, 300, 0), ]; const bom = buildBOM(els); const trussRows = bom.rows.filter((r) => r.type === 'Traverse'); expect(trussRows).toHaveLength(3); const counts = trussRows.map((r) => r.count); expect(counts).toEqual([1, 1, 1]); }); it('unterschiedliche Breiten = getrennte Positionen (100x29 vs 100x40)', () => { const els = [ truss('t1', 0, 0, 100, 0, 29), truss('t2', 0, 0, 100, 0, 40), ]; const bom = buildBOM(els); expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2); }); it('Boxcorner als eigener Typ mit armLength als Laenge', () => { const els = [ corner('c1', 0, 0, 50, 0), corner('c2', 100, 100, 50, 90), corner('c3', 200, 200, 75, 0), ]; const bom = buildBOM(els); const cornerRows = bom.rows.filter((r) => r.type === 'Eck-Traverse'); expect(cornerRows).toHaveLength(2); // 50cm x2 + 75cm x1 const r50 = cornerRows.find((r) => r.length === 50)!; expect(r50.count).toBe(2); }); it('Nicht-Traversen (chair etc.) werden ignoriert', () => { const els = [chair('s1'), truss('t1', 0, 0, 200, 0)]; const bom = buildBOM(els); expect(bom.rows.every((r) => r.type !== 'chair')).toBe(true); }); it('leere Zeichnung: leere Stueckliste ohne Crash', () => { const bom = buildBOM([]); expect(bom.rows).toHaveLength(0); expect(bom.totalLengthM).toBe(0); }); it('Gesamtlaenge: 200+100+300cm Traversen + 2x50cm Ecken = 7.00m', () => { const els = [ truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 100, 0), truss('t3', 0, 0, 300, 0), corner('c1', 0, 0, 50, 0), corner('c2', 0, 0, 50, 90), ]; const bom = buildBOM(els); // Traversen 6.00m + 2 Ecken je 2 Arme = 8.00m expect(bom.totalLengthM).toBeCloseTo(8.0, 2); }); it('Boxcorner zaehlt BEIDE Arme in die Gesamtlaenge (50er-Ecke = 1.00m)', () => { const els = [corner('c1', 0, 0, 50, 0)]; const bom = buildBOM(els); expect(bom.totalLengthM).toBeCloseTo(1.0, 2); }); it('Positionen sind sortiert: Traversen vor Ecken, dann nach Laenge absteigend', () => { const els = [ corner('c1', 0, 0, 50, 0), truss('t1', 0, 0, 100, 0), truss('t2', 0, 0, 300, 0), ]; const bom = buildBOM(els); const types = bom.rows.map((r) => r.type); // Traversen zuerst (nach Laenge absteigend), dann Ecken expect(types.indexOf('Traverse')).toBeLessThan(types.indexOf('Eck-Traverse')); const trussLens = bom.rows.filter((r) => r.type === 'Traverse').map((r) => r.length); expect(trussLens[0]).toBeGreaterThanOrEqual(trussLens[trussLens.length - 1]); }); }); // ─── exportBOMCsv ───────────────────────────────────────── describe('CAD-9: exportBOMCsv', () => { it('CSV hat Kopfzeile und Zeilen pro Position mit Semikolon/ Komma', () => { const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 90)]; const bom = buildBOM(els); const csv = exportBOMCsv(bom); const lines = csv.split('\n'); expect(lines[0]).toContain('Pos'); expect(lines[0]).toContain('Typ'); expect(lines[0]).toContain('Anzahl'); expect(lines).toHaveLength(2 + 1); // Header + 1 Position + ggf. Summenzeile }); it('CSV enthaelt Laenge und Anzahl der Position', () => { const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 0)]; const csv = exportBOMCsv(buildBOM(els)); expect(csv).toContain('200'); expect(csv).toContain('2'); }); it('CSV: Semikolon-Separatoren (Excel-DE-freundlich) oder Komma + konsistent', () => { const csv = exportBOMCsv(buildBOM([truss('t1', 0, 0, 200, 0)])); // Konsistenz: Jede Datenzeile hat dieselbe Anzahl Separatoren const lines = csv.trim().split('\n'); const sepCounts = lines.map((l) => (l.match(/;/g) || []).length); expect(new Set(sepCounts).size).toBe(1); }); it('leere Stueckliste: nur Kopfzeile', () => { const csv = exportBOMCsv(buildBOM([])); expect(csv.trim().split('\n')).toHaveLength(1); }); });