import test from 'node:test'; import assert from 'node:assert/strict'; import { solveAffine, applyAffine, affineResidual, readVacTelemetry, autoCalibrate, thinPath, pushTrailPoint, isVacMoving, isVacSourceState, vacMapIdFromAttrs, vacMapIdWithFallback } from '../test-build/vacuum.js'; test('solveAffine recovers rotation+scale+mirror+offset exactly', () => { // target = mirror-X, rotate 90°, scale 0.02, offset (300, 400) const f = ([x, y]) => [300 + 0.02 * y, 400 + 0.02 * x]; const src = [[1000, 2000], [8000, 2500], [3000, 9000], [7000, 7000]]; const m = solveAffine(src.map((p) => [p, f(p)])); assert.ok(m); for (const p of [[5000, 5000], [0, 0], [12000, 3000]]) { const got = applyAffine(m, p[0], p[1]); const want = f(p); assert.ok(Math.hypot(got[0] - want[0], got[1] - want[1]) < 1e-6, String(p)); } assert.ok(affineResidual(m, src.map((p) => [p, f(p)])) < 1e-6); }); test('solveAffine rejects degenerate input', () => { assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 1], [1, 1]]]), null); // 2 pairs // collinear assert.equal(solveAffine([[[0, 0], [0, 0]], [[1, 0], [1, 0]], [[2, 0], [2, 0]]]), null); assert.equal(solveAffine([[[0, NaN], [0, 0]], [[1, 0], [1, 0]], [[2, 3], [2, 0]]]), null); }); test('readVacTelemetry: Map Extractor shape', () => { const t = readVacTelemetry({ vacuum_position: { x: 25500, y: 24800, a: 271 }, path: [{ x: 25000, y: 24000 }, { x: 25100, y: 24100 }], rooms: { 16: { name: 'Kitchen', x0: 20000, y0: 20000, x1: 26000, y1: 25000 } }, map_name: '0', }); assert.deepEqual(t.pos, { x: 25500, y: 24800, a: 271 }); assert.deepEqual(t.path, [[25000, 24000], [25100, 24100]]); assert.equal(t.rooms[0].name, 'Kitchen'); assert.equal(t.rooms[0].cx, 23000); assert.equal(t.mapId, '0'); }); test('readVacTelemetry: Valetudo/Tasshack shapes + junk safety', () => { const t = readVacTelemetry({ robot_position: { x: '120', y: '340', angle: '90' }, rooms: [{ id: 7, name: 'Спальня', cx: 10, cy: 20 }] }); assert.deepEqual(t.pos, { x: 120, y: 340, a: 90 }); assert.equal(t.rooms[0].id, '7'); assert.equal(readVacTelemetry({ vacuum_position: { x: 'nope', y: 1 } }), null); assert.equal(readVacTelemetry({}), null); assert.equal(readVacTelemetry(null), null); assert.ok(isVacSourceState({ attributes: { vacuum_position: { x: 1, y: 2 } } })); assert.ok(!isVacSourceState({ attributes: { battery: 1 } })); }); test('readVacTelemetry: Tasshack room centres come as plain x/y', () => { // shape captured from a live Dreame X50 Master (dacha, 2026-07-31) const t = readVacTelemetry({ vacuum_position: { x: 1399, y: -55, a: 181 }, rooms: { 2: { room_id: 2, name: 'Кладовка', x0: 800, y0: -2000, x1: 4200, y1: 300, x: 2575, y: -825 } }, }); assert.equal(t.rooms[0].name, 'Кладовка'); // bbox wins when present (both are valid anchors); x/y covers bbox-less dialects assert.equal(t.rooms[0].cx, 2500); const t2 = readVacTelemetry({ vacuum_position: { x: 0, y: 0 }, rooms: { 2: { name: 'Кладовка', x: 2575, y: -825 } } }); assert.equal(t2.rooms[0].cx, 2575); }); test('autoCalibrate matches by name and solves', () => { const f = ([x, y]) => [0.01 * x + 100, -0.01 * y + 900]; const vac = [ { id: '1', name: 'Kitchen', cx: 1000, cy: 2000 }, { id: '2', name: 'Bed Room', cx: 9000, cy: 2500 }, { id: '3', name: 'office', cx: 3000, cy: 8000 }, { id: '4', name: 'Garage', cx: 5000, cy: 5000 }, // no plan match ]; const plan = [ { name: 'kitchen', cx: f([1000, 2000])[0], cy: f([1000, 2000])[1] }, { name: 'BEDROOM', cx: f([9000, 2500])[0], cy: f([9000, 2500])[1] }, { name: 'Office', cx: f([3000, 8000])[0], cy: f([3000, 8000])[1] }, ]; const r = autoCalibrate(vac, plan); assert.ok(r); assert.deepEqual(r.matched, ['Kitchen', 'Bed Room', 'office']); assert.ok(r.residual < 1e-6); // two matches only -> null assert.equal(autoCalibrate(vac.slice(0, 2), plan.slice(0, 2)), null); }); test('thinPath keeps corners, drops straight-line noise', () => { const pts = []; for (let i = 0; i <= 100; i++) pts.push([i, 0]); for (let i = 1; i <= 100; i++) pts.push([100, i]); const out = thinPath(pts, 0.5); assert.ok(out.length <= 5, String(out.length)); assert.deepEqual(out[0], [0, 0]); assert.deepEqual(out[out.length - 1], [100, 100]); assert.ok(out.some((p) => p[0] === 100 && p[1] === 0)); // the corner survives }); test('pushTrailPoint dedups and respects the cap', () => { let buf = []; for (let i = 0; i < 3000; i++) buf = pushTrailPoint(buf, [i, (i * 7) % 13], 0.5); assert.ok(buf.length <= 600, String(buf.length)); buf = pushTrailPoint(buf, buf[buf.length - 1], 0.5); // dup ignored assert.ok(buf.length <= 600); }); test('isVacMoving', () => { assert.ok(isVacMoving('cleaning')); assert.ok(isVacMoving('returning')); assert.ok(!isVacMoving('docked')); assert.ok(!isVacMoving('idle')); assert.ok(!isVacMoving(undefined)); }); test('fitMatrix/fitFromMatrix round-trip, mirror and quarters', async () => { const { fitMatrix, fitFromMatrix, initialFit, reanchorFit } = await import('../test-build/vacuum.js'); for (const rot of [0, 90, 180, 270]) for (const mir of [false, true]) { const p = { ox: 123.4, oy: -55.5, s: 0.083, rot, mir }; const q = fitFromMatrix(fitMatrix(p)); assert.equal(q.rot, rot, `rot ${rot} mir ${mir}`); assert.equal(q.mir, mir); assert.ok(Math.abs(q.s - p.s) < 1e-9 && Math.abs(q.ox - p.ox) < 1e-9 && Math.abs(q.oy - p.oy) < 1e-9); } // the real X50 matrix shape: X forward, Y flipped == mir + 180? decompose sanity const q = fitFromMatrix([0.08, 0, 590, 0, -0.08, 677]); assert.equal(q.mir, true); // initialFit centres the map bbox on the canvas const rooms = [{ id: '1', name: 'A', cx: 500, cy: 500, x0: 0, y0: 0, x1: 1000, y1: 1000 }]; const f = initialFit(rooms, [0, 0, 1000, 1000]); const m = fitMatrix(f); const c = applyAffine(m, 500, 500); assert.ok(Math.abs(c[0] - 500) < 1e-6 && Math.abs(c[1] - 500) < 1e-6); assert.ok(Math.abs(1000 * f.s - 600) < 1e-6); // 60% of the canvas assert.equal(f.mir, true); // reanchor keeps the chosen source point fixed through a rotation const p2 = { ...f, rot: 90 }; const r2 = reanchorFit(p2, f, 500, 500); const c2 = applyAffine(fitMatrix(r2), 500, 500); assert.ok(Math.abs(c2[0] - c[0]) < 1e-6 && Math.abs(c2[1] - c[1]) < 1e-6); }); // HP-1540-02: the map-id contract, mirrored by trails.py resolve_map_id — // the first value that is not null/undefined wins; zero and '' are valid ids. test('vacMapIdFromAttrs: first NOT-nullish value wins, zero survives', () => { assert.equal(vacMapIdFromAttrs({ map_index: 0 }), '0'); assert.equal(vacMapIdFromAttrs({ map_index: '0' }), '0'); assert.equal(vacMapIdFromAttrs({ map_name: '', selected_map: 'Floor' }), ''); assert.equal(vacMapIdFromAttrs({ map_name: 'A', map_index: 0 }), 'A'); assert.equal(vacMapIdFromAttrs({ current_map: 2 }), '2'); assert.equal(vacMapIdFromAttrs({ selected_map: 'Vac' }), 'Vac'); assert.equal(vacMapIdFromAttrs({}), 'default'); }); // HP-1541-01: the vacuum-entity fallback half of the contract. Truthiness // here turned selected_map: 0 into 'default' while the server recorder // stored the trail under '0' — reloads never showed the saved run. test('vacMapIdWithFallback: selected_map 0 / "0" / "" survive, nullish falls back', () => { assert.equal(vacMapIdWithFallback('default', 0), '0'); assert.equal(vacMapIdWithFallback('default', '0'), '0'); assert.equal(vacMapIdWithFallback('default', ''), ''); assert.equal(vacMapIdWithFallback('default', 'Vac'), 'Vac'); assert.equal(vacMapIdWithFallback('default', null), 'default'); assert.equal(vacMapIdWithFallback('default', undefined), 'default'); assert.equal(vacMapIdWithFallback('1', 0), '1'); // telemetry wins over fallback }); // HP-1541-01 cross-runtime contract: for the same inputs the card-side chain // (vacMapIdFromAttrs -> vacMapIdWithFallback) must yield exactly what // trails.py resolve_map_id stores. Mirrored by // tests_backend/test_trail_recorder.py::test_map_id_contract_first_not_none_wins. test('map-id contract: frontend chain matches backend resolve_map_id', () => { const chain = (srcAttrs, vacSel) => vacMapIdWithFallback(vacMapIdFromAttrs(srcAttrs), vacSel); assert.equal(chain({}, 0), '0'); assert.equal(chain({}, '0'), '0'); assert.equal(chain({}, ''), ''); assert.equal(chain({}, 'Vac'), 'Vac'); assert.equal(chain({}, undefined), 'default'); assert.equal(chain({ map_index: 0 }, 'Vac'), '0'); // source wins over vacuum }); test('readVacTelemetry keeps numeric zero map_index as map id (HP-1540-02)', () => { const t = readVacTelemetry({ vacuum_position: { x: 1, y: 2 }, map_index: 0 }); assert.equal(t.mapId, '0'); });