Files
houseplan-card/test/radar-geometry.test.mjs
T
2026-09-09 03:54:42 +03:00

74 lines
3.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
projectRadarLocal, radarLengthCm, radarMedianSample, solveRadarTwoPoint,
} from '../test-build/radar-geometry.js';
const shared = JSON.parse(readFileSync(new URL('./fixtures/radar-source-boundaries.json', import.meta.url)));
test('shared synthetic source boundaries project identically in the browser', () => {
for (const item of shared.projection) {
const localCm = item.local.map((value) => radarLengthCm(value, item.unit));
const actual = projectRadarLocal(
item.mount, item.heading_deg, item.mirror, item.cell_cm, localCm,
);
assert.ok(Math.abs(actual[0] - item.expected[0]) < 1e-12, item.name);
assert.ok(Math.abs(actual[1] - item.expected[1]) < 1e-12, item.name);
}
});
test('radar projection uses persisted 0..1 plan coordinates and exact units', () => {
assert.equal(radarLengthCm(1000, 'mm'), 100);
assert.deepEqual(projectRadarLocal([.5, .5], 0, false, 5, [120, 0]), [.6, .5]);
assert.deepEqual(projectRadarLocal([.5, .5], 0, false, 5, [0, 120]), [.5, .4]);
});
test('two reference solve keeps physical scale rigid', () => {
const fit = solveRadarTwoPoint([.5, .5], [[100, 0], [0, 100]], [
[.5 + 100 / 1200, .5], [.5, .5 - 100 / 1200],
], 5);
assert.ok(Math.abs(fit.headingDeg) < 1e-9);
assert.equal(fit.mirror, false);
assert.ok(fit.rmsCm < 1e-9);
assert.throws(() => solveRadarTwoPoint([.5, .5], [[100, 0], [200, 0]], [
[.6, .5], [.7, .5],
], 5), /bad_references/);
});
test('two-point calibration keeps every distance and fit guard distinct', () => {
const mount = [.5, .5];
const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200];
assert.throws(() => solveRadarTwoPoint(mount, [[49, 0], [0, 100]], [
plan(49, 0), plan(0, 100),
], 5), /bad_references/, 'references under 50 cm are rejected with actionable guidance');
assert.throws(() => solveRadarTwoPoint(mount, [[100, 0], [0, 100]], [
plan(100, 0), plan(-50, 86.603),
], 5), /invalid_selection/, 'RMS over 20 cm is rejected');
assert.throws(() => solveRadarTwoPoint(mount, [[100, 0], [0, 100]], [
plan(100, 0), plan(-64.279, 76.604),
], 5), /invalid_selection/, 'an individual error over 30 cm is rejected');
});
test('two-point calibration exposes an ambiguous mirror instead of choosing silently', () => {
const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200];
assert.throws(() => solveRadarTwoPoint([.5, .5], [[-20, 80], [20, 80]], [
plan(0, 80), plan(0, 80),
], 5), /ambiguous_sources/);
});
test('two-point calibration rejects a reference with a hidden radial mismatch', () => {
const mount = [.5, .5];
const plan = (x, y) => [.5 + x / 1200, .5 - y / 1200];
assert.throws(() => solveRadarTwoPoint(mount, [[100, 0], [0, 100]], [
plan(125, 0), plan(0, 100),
], 5), /invalid_selection/,
'a 25 cm radial mismatch must fail even though RMS and per-reference error limits pass');
});
test('capture median rejects one sample more than 15cm away', () => {
assert.deepEqual(radarMedianSample([[100, 200], [102, 198], [101, 201]]), [101, 200]);
assert.throws(() => radarMedianSample([[100, 200], [101, 199], [140, 240]]), /bad_fit/);
});