fix: harden presence radar stage one

User-Visible: yes
Issue: #485
This commit is contained in:
Matysh
2026-09-09 03:52:23 +03:00
parent 63eb4f315c
commit d12488f40d
29 changed files with 552 additions and 231 deletions
+18 -1
View File
@@ -2,7 +2,8 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
radarConfigFromDraft, radarDraft, radarSourceCandidates, recognizeRadar,
freshRadarInstallationId, radarAfterBindingChange, radarConfigFromDraft, radarDraft,
radarSourceCandidates, recognizeRadar,
} from '../test-build/radar-editor.js';
const space = { id: 'floor', title: 'Floor', cellCm: 5, rooms: [
@@ -50,6 +51,22 @@ test('coordinate-looking entity names alone do not claim an LD2450 adapter', ()
'sensor.machine_target_1_x', 'sensor.machine_target_1_y',
] });
assert.equal(recognizeRadar(ordinary, {}).eligible, false);
const fuzzy = device({ model: 'Presence Radar FP2', allEntities: [
'sensor.machine_target_1_x', 'sensor.machine_target_1_y',
] });
assert.equal(recognizeRadar(fuzzy, {}).eligible, false);
});
test('rebinding preserves only an already-saved radar and a new installation gets a new id', () => {
const pending = radarDraft(device(), space, [50, 60], {}, true);
assert.deepEqual(radarAfterBindingChange(pending, true, false), {
radar: null, radarEligible: false, radarTouched: true, radarRemove: true,
});
const saved = { ...pending, original: { version: 1 } };
assert.deepEqual(radarAfterBindingChange(saved, false, false), {
radar: saved, radarEligible: true, radarTouched: false, radarRemove: false,
});
assert.notEqual(freshRadarInstallationId(), freshRadarInstallationId());
});
test('manual exact sources may come from separate HA devices', () => {
+21
View File
@@ -37,6 +37,27 @@ test('two reference solve keeps physical scale rigid', () => {
], 5), /invalid_selection/);
});
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), /invalid_selection/, 'references under 50 cm are rejected');
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('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/);
+7
View File
@@ -26,10 +26,14 @@ test('radar frames reject malformed geometry and cap nested payloads', () => {
],
ranges: [{ id: 'r', x: 10, y: 20, radius: 3, reported_at: 100, expires_at: 103,
segments: [[[1, 2], [3, 4]], [[Number.NaN, 2], [3, 4]]] }],
zones: [{ id: 'desk', state: true, polygon: [[.1, .1], [.3, .1], [.3, .3]] },
{ id: 'broken', state: true, polygon: [[.1, Number.NaN], [.2, .2], [.3, .3]] }],
}));
assert.ok(normalized);
assert.equal(normalized.targets.length, 1);
assert.deepEqual(normalized.ranges[0].segments, [[[1, 2], [3, 4]]]);
assert.deepEqual(normalized.zones[0].polygon, [[.1, .1], [.3, .1], [.3, .3]]);
assert.equal(normalized.zones[1].polygon, undefined);
assert.equal(normalizeRadarFrame({ marker_id: 'x' }), null);
});
@@ -84,6 +88,9 @@ test('radar health respects disabled config before runtime state', () => {
const radar = { version: 1, enabled: false, show_live: true };
assert.equal(radarHealthI18nKey(radar, 'ok'), 'radar.health_disabled');
assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'ok'), 'radar.health_ok');
assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'partial'), 'radar.health_partial');
assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'restricted'), 'radar.health_restricted');
assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'incomplete'), 'radar.health_incomplete');
assert.equal(radarHealthI18nKey({ ...radar, enabled: true }, 'future_status'),
'radar.health_unknown');
});
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderRadarLive } from '../test-build/radar-render.js';
const frame = (state) => ({
marker_id: 'radar', targets: [], ranges: [],
zones: [{ id: 'desk', state, polygon: [[.1, .1], [.3, .1], [.3, .3]] }],
});
test('known occupied radar zones render server-owned geometry only', () => {
const occupied = renderRadarLive([frame(true)], { x: 0, y: 0, w: 1000, h: 1000 }, (p) => p);
const zone = occupied.values[0].values[4][0];
assert.match(zone.strings.join(''), /class="radar-zone"/);
assert.equal(zone.values[0], 'desk');
assert.equal(zone.values[1], '100,100 300,100 300,300');
const empty = renderRadarLive([frame(false)], { x: 0, y: 0, w: 1000, h: 1000 }, (p) => p);
assert.equal(empty, Symbol.for('lit-nothing'));
});
+25 -4
View File
@@ -23,6 +23,7 @@ test('on-plan installation changes only the editor draft until ordinary Save', (
const controller = new RadarSetupController({
hass: () => ({}), requestUpdate() {}, t: (key) => key,
configFromDraft: radarConfigFromDraft, apply: (next) => { applied = next; },
confirmDiscard: async () => true,
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1], [0, 1]],
@@ -40,17 +41,37 @@ test('on-plan installation changes only the editor draft until ordinary Save', (
assert.equal(radarConfigFromDraft(applied, 5).mount.x, .25);
});
test('cancelling calibration releases its draft subscription exactly once', () => {
test('cancelling calibration releases its draft subscription exactly once', async () => {
const controller = new RadarSetupController({
hass: () => ({}), requestUpdate() {}, t: (key) => key,
configFromDraft: radarConfigFromDraft, apply() {},
configFromDraft: radarConfigFromDraft, apply() {}, confirmDiscard: async () => true,
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
}, 5, 7), true);
let calls = 0;
controller.unsubscribe = () => { calls += 1; };
assert.equal(controller.cancel(), true);
assert.equal(controller.cancel(), false);
assert.equal(await controller.cancel(), true);
assert.equal(await controller.cancel(), true);
assert.equal(calls, 1);
});
test('dirty calibration stays open when discard confirmation is rejected', async () => {
let confirmations = 0;
const controller = new RadarSetupController({
hass: () => ({}), requestUpdate() {}, t: (key) => key,
configFromDraft: radarConfigFromDraft, apply() {},
confirmDiscard: async () => { confirmations += 1; return false; },
});
assert.equal(controller.begin('radar', draft(), {
id: 'living', name: 'Living', poly: [[0, 0], [1, 0], [1, 1]],
}, 5, 7), true);
controller.choosePoint(pointer(250, 400));
let cleanups = 0;
controller.unsubscribe = () => { cleanups += 1; };
assert.equal(await controller.cancel(), false);
assert.equal(confirmations, 1);
assert.equal(cleanups, 0);
assert.ok(controller.active);
});