task(CAD-9): bill of materials - buildBOM grouping by type+size with counts and total truss meters (corners count both arms), CSV export excel-friendly, dialog with table and download button
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user