/** * Unit-Test für scripts/4_yAxis_rotation_reconstruction.py * * Das Python-Skript wird via child_process aufgerufen. * stdout enthält das JSON-Ergebnis (letzte Zeile – davor ggf. Leerzeilen). * stderr enthält menschenlesbare Zusammenfassung (wird ignoriert). * * Test-Daten: test/y-axis-finder-examples/ (drei Timestamps → Arm1-Bogen) * * Erwartete Werte stammen aus verifiziertem Lauf (2026-06-12): * Marker 197, 218, 219 → rotieren (12 Punkte, 3 × 4 Ecken) * Marker 201, 204 → gefiltert (Bewegung < 10 mm) * axisDir ≈ [0.9995, 0.029, -0.015] (fast reine X-Achse des Roboters) */ const { execFileSync } = require('child_process'); const path = require('path'); const SCRIPT = path.join(__dirname, '..', 'scripts', '4_yAxis_rotation_reconstruction.py'); const EXAMPLES = path.join(__dirname, 'y-axis-finder-examples'); const POS_A = path.join(EXAMPLES, '20260612_190019'); const POS_B = path.join(EXAMPLES, '20260612_190104'); const POS_C = path.join(EXAMPLES, '20260612_190241'); // ── Hilfsfunktion ────────────────────────────────────────────────────────────── const { spawnSync } = require('child_process'); /** * Ruft das Python-Skript auf und gibt das geparste JSON zurück. * Verwendet spawnSync (statt execFileSync) damit ein Fehler-Exit-Code (ok:false) * nicht sofort eine Exception auslöst – wir parsen das stdout trotzdem. * stdout kann mehrere Zeilen haben – JSON ist die letzte nicht-leere Zeile. */ function runScript(posA, posB, posC, extraArgs = []) { const proc = spawnSync('python3', [SCRIPT, posA, posB, posC, ...extraArgs], { encoding: 'utf-8', // stderr läuft zum Terminal durch → menschenlesbarer Summary sichtbar }); if (proc.error) throw proc.error; // z. B. python3 nicht gefunden const stdout = proc.stdout || ''; const lastJsonLine = stdout .trim() .split('\n') .filter(l => l.trim().startsWith('{')) .at(-1); if (!lastJsonLine) { throw new Error( `Kein JSON in stdout.\nstdout: ${stdout}\nstderr: ${proc.stderr}` ); } return JSON.parse(lastJsonLine); } // ── Haupt-Test-Suite ────────────────────────────────────────────────────────── describe('4_yAxis_rotation_reconstruction.py – Arm1-Testdaten (190019 / 190104 / 190241)', () => { /** Einmalig ausführen – dauert ca. 1 s */ let r; beforeAll(() => { r = runScript(POS_A, POS_B, POS_C); }); // ── Grundstatus ─────────────────────────────────────────────────────────── test('Berechnung erfolgreich (ok: true)', () => { expect(r.ok).toBe(true); }); // ── Marker-Anzahlen ─────────────────────────────────────────────────────── test('5 gemeinsame fremd-Marker erkannt', () => { expect(r.numMarkersCommon).toBe(5); expect(r.commonMarkerIds).toEqual([197, 201, 204, 218, 219]); }); test('3 Marker tatsächlich genutzt (197, 218, 219)', () => { expect(r.numMarkersUsed).toBe(3); expect(r.usedMarkerIds).toEqual([197, 218, 219]); }); test('12 Punkte (3 Marker × 4 Ecken)', () => { expect(r.numPoints).toBe(12); }); // ── Filterung ───────────────────────────────────────────────────────────── test('2 Marker wegen zu geringer Bewegung gefiltert (201, 204)', () => { expect(r.skipped).toHaveLength(2); const skippedIds = r.skipped.map(s => s.marker_id).sort((a, b) => a - b); expect(skippedIds).toEqual([201, 204]); // Beide mit korrektem Grund r.skipped.forEach(s => { expect(s.reason).toMatch(/Bewegung zu gering/); expect(s.max_movement_mm).toBeLessThan(10.0); // unter dem Threshold }); }); // ── Residuen ────────────────────────────────────────────────────────────── test('Abstandsresiduen: Mittelwert < 10 mm', () => { expect(r.residual_dist_mean_mm).toBeLessThan(10); }); test('Abstandsresiduen: Maximum < 15 mm', () => { expect(r.residual_dist_max_mm).toBeLessThan(15); }); test('Winkelresiduen: Mittelwert < 5°', () => { expect(r.residual_angle_mean_deg).toBeLessThan(5); }); test('Winkelresiduen: Maximum < 7°', () => { expect(r.residual_angle_max_deg).toBeLessThan(7); }); // ── Achsenrichtung ──────────────────────────────────────────────────────── test('axisDir ist ein Einheitsvektor (|axisDir| ≈ 1)', () => { const [ax, ay, az] = r.axisDir; const len = Math.sqrt(ax * ax + ay * ay + az * az); expect(len).toBeCloseTo(1.0, 4); // ≤ 0.00005 Abweichung }); test('Achse liegt fast auf X-Achse des Roboters (axisDir[0] > 0.99)', () => { // Physikalisch: Arm1 schwingt auf/ab → Drehachse = X-Richtung expect(r.axisDir[0]).toBeGreaterThan(0.99); }); test('axisDir hat 3 Komponenten', () => { expect(r.axisDir).toHaveLength(3); }); test('axisPoint_mm hat 3 Komponenten', () => { expect(r.axisPoint_mm).toHaveLength(3); }); // ── Marker-Ergebnis-Struktur ────────────────────────────────────────────── test('markerResults enthält genau 3 Einträge (verwendete Marker)', () => { expect(r.markerResults).toHaveLength(3); }); test('jeder Marker-Eintrag hat n_points_used === 4', () => { r.markerResults.forEach(mr => { expect(mr.n_points_used).toBe(4); expect(mr.circumcenter_mean_mm).toHaveLength(3); }); }); }); // ── Parametertest: benutzerdefinierter min-movement-Threshold ───────────────── describe('4_yAxis_rotation_reconstruction.py – min-movement-Parameter', () => { test('mit --min-movement 0 werden alle 5 Marker genutzt (kein Filter)', () => { const r = runScript(POS_A, POS_B, POS_C, ['--min-movement', '0']); // Alle 5 gemeinsamen Marker werden versucht; Ergebnis ist schlechter expect(r.ok).toBe(true); expect(r.numMarkersUsed).toBe(5); expect(r.numMarkersCommon).toBe(5); // skipped enthält nur noch ggf. degenerierte Punkte (keine Bewegungs-Skips) const movementSkips = r.skipped.filter(s => s.reason && s.reason.includes('Bewegung zu gering')); expect(movementSkips).toHaveLength(0); }); test('mit --min-movement 1000 werden alle Marker gefiltert → ok: false', () => { // Die rotierenden Marker (197, 218, 219) bewegen sich >100 mm, // erst ab 1000 mm werden auch sie herausgefiltert. const r = runScript(POS_A, POS_B, POS_C, ['--min-movement', '1000']); expect(r.ok).toBe(false); expect(r.error).toBeDefined(); }); });