/** * homingXEstimate.test.js * ======================= * Unit-Test für server/homingXEstimate.cjs (reine X-Schätzungs-Geometrie). * * Sichert insbesondere den Bug vom 2026-06-14 ab: Vorher setzte die Schätzung * x_slider = Mittelwert der beobachteten world-x und vergaß den kinematischen * Gelenk-Offset (Arm1.origin.x = 110). Dadurch lagen die Modell-Marker ~110 mm * zu weit in +x. Korrekt: slider_i = beobachtetes_x − Modell_x(slider=0). */ const fs = require('fs'); const path = require('path'); const { modelWorldXAtSliderZero, estimateXFromParsed } = require('../server/homingXEstimate.cjs'); // ── Synthetische Kinematik (Struktur wie robot.json, minimal) ────────────────── function makeLinks() { return { Board: {}, // Root, kein jointToParent Base: { parent: 'Board', jointToParent: { type: 'linear', axis: [1, 0, 0], origin: [0, 0, 16] }, markers: [] }, Arm1: { parent: 'Base', jointToParent: { type: 'revolute', axis: [-1, 0, 0], origin: [110, 108, 45] }, markers: [ { id: 198, position: [0, -160, 35] }, { id: 229, position: [0, -250, 35] }, { id: 197, position: [-35, -250, 0] }, ], }, Ellbow: { parent: 'Arm1', jointToParent: { type: 'revolute', axis: [-1, 0, 0], origin: [0, -250, 0] }, markers: [ { id: 244, position: [125, 0, 0] } ], }, Arm2: { parent: 'Ellbow', jointToParent: { type: 'revolute', axis: [0, -1, 0], origin: [90, 0, 0] }, // dreht um y → NICHT x-sicher markers: [ { id: 300, position: [0, -100, 0] } ], }, }; } describe('modelWorldXAtSliderZero', () => { const links = makeLinks(); test('summiert origin.x entlang x-Rotations-Kette (Arm1)', () => { // Arm1.origin.x (110) + local x (0) expect(modelWorldXAtSliderZero(links, 'Arm1', [0, -160, 35])) .toEqual({ worldX: 110, xSafe: true }); // local x (-35) wird mitgenommen expect(modelWorldXAtSliderZero(links, 'Arm1', [-35, -250, 0])) .toEqual({ worldX: 75, xSafe: true }); }); test('Ellbow bleibt x-sicher (gesamte Kette dreht um x)', () => { // Ellbow.origin.x (0) + Arm1.origin.x (110) + local x (125) expect(modelWorldXAtSliderZero(links, 'Ellbow', [125, 0, 0])) .toEqual({ worldX: 235, xSafe: true }); }); test('Arm2 ist NICHT x-sicher (dreht um y)', () => { const r = modelWorldXAtSliderZero(links, 'Arm2', [0, -100, 0]); expect(r.xSafe).toBe(false); }); test('unbekannter Link → worldX = local x, xSafe bleibt true', () => { expect(modelWorldXAtSliderZero(links, 'DoesNotExist', [7, 0, 0])) .toEqual({ worldX: 7, xSafe: true }); }); }); describe('estimateXFromParsed', () => { const links = makeLinks(); test('rechnet den Gelenk-Offset heraus (Arm1 + Ellbow), nicht roher Mittelwert', () => { // Alle Marker sind so gewählt, dass der implizierte Slider exakt 40 ist. const aruco = { markers: [ { marker_id: 198, link: 'Arm1', position_mm: [150, 0, 0] }, // 150 - 110 = 40 { marker_id: 229, link: 'Arm1', position_mm: [150, 0, 0] }, // 150 - 110 = 40 { marker_id: 197, link: 'Arm1', position_mm: [115, 0, 0] }, // 115 - 75 = 40 { marker_id: 244, link: 'Ellbow', position_mm: [275, 0, 0] }, // 275 - 235 = 40 ]}; expect(estimateXFromParsed(aruco, links)).toBeCloseTo(40, 6); // Roher Mittelwert (alter Bug) wäre (150+150+115+275)/4 = 172.5 → NICHT das. expect(estimateXFromParsed(aruco, links)).not.toBeCloseTo(172.5, 1); }); test('überspringt Board, unbekannte IDs, x-unsichere Ketten und fehlende Positionen', () => { const aruco = { markers: [ { marker_id: 198, link: 'Arm1', position_mm: [150, 0, 0] }, // zählt → 40 { marker_id: 229, link: 'Arm1', position_mm: [150, 0, 0] }, // zählt → 40 { marker_id: 5, link: 'Board', position_mm: [999, 0, 0] }, // Board → skip { marker_id: 999, link: 'Arm1', position_mm: [500, 0, 0] }, // unbekannte ID → skip { marker_id: 300, link: 'Arm2', position_mm: [500, 0, 0] }, // x-unsicher → skip { marker_id: 197, link: 'Arm1' }, // kein position_mm → skip ]}; expect(estimateXFromParsed(aruco, links)).toBeCloseTo(40, 6); }); test('Fallback ohne robot.json: roher Mittelwert der Nicht-Board-Marker', () => { const aruco = { markers: [ { marker_id: 198, link: 'Arm1', position_mm: [150, 0, 0] }, { marker_id: 229, link: 'Arm1', position_mm: [150, 0, 0] }, { marker_id: 197, link: 'Arm1', position_mm: [115, 0, 0] }, { marker_id: 5, link: 'Board', position_mm: [999, 0, 0] }, // Board zählt nicht ]}; // (150 + 150 + 115) / 3 = 138.333… expect(estimateXFromParsed(aruco, {})).toBeCloseTo(138.3333, 3); }); test('keine Marker → 0.0', () => { expect(estimateXFromParsed({ markers: [] }, links)).toBe(0.0); expect(estimateXFromParsed({}, links)).toBe(0.0); }); }); describe('Regression gegen echte robot.json', () => { test('Arm1-Marker bei beobachtetem x=150/150/115 → Slider ≈ 40 (nicht ~138)', () => { const robotPath = path.resolve(__dirname, '../scripts/robot_1781069752019.json'); const links = JSON.parse(fs.readFileSync(robotPath, 'utf8')).links; // Marker-IDs/locals stammen aus robot.json (Arm1: 198 [0,..], 229 [0,..], 197 [-35,..]); // Arm1.origin.x = 110, Base.origin.x = 0. const aruco = { markers: [ { marker_id: 198, link: 'Arm1', position_mm: [150, 12, 60] }, { marker_id: 229, link: 'Arm1', position_mm: [150, -8, 60] }, { marker_id: 197, link: 'Arm1', position_mm: [115, 3, 25] }, ]}; const x = estimateXFromParsed(aruco, links); expect(x).toBeCloseTo(40, 6); // korrekt: Offset herausgerechnet expect(x).not.toBeCloseTo(138.3, 1); // alter Bug (roher Mittelwert) ausgeschlossen }); });