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

255 lines
10 KiB
TypeScript

/**
* 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<string, unknown> = {}) {
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);
});
});