Files
houseplan-card/test/radar-editor.test.mjs
T
Codex 03983946c5 Источники радара сравниваются по смыслу, а не по тексту
Гард `keepTwoPoint` решал, сохранять ли двухточечную калибровку, сравнением
`JSON.stringify`, то есть текста. Порядок ключей `sources` при этом меняет сам
билдер: `slots`/`ranges`/`zones`/`occupancy_entity`/`count_entity` он удаляет и
дописывает заново, а `availability_entity` остаётся на месте и уезжает в начало.
Конфиг, только что записанный этим же билдером, при следующем открытии
сравнивался неравным — и радар с `availability_entity` терял `refs` и `rms_cm`,
получая `method: manual`, при первом же обычном сохранении.

Сравнение стало каноничным по порядку ключей объектов и осталось чувствительным
к порядку элементов массивов: позиция слота — это его `target_N`. Проекция не
затронута — она считается от `mount`, `cell_cm` и `mirror`.

Issue: #567
User-Visible: no
2026-09-13 23:23:30 +03:00

266 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
freshRadarInstallationId, radarAfterBindingChange, radarConfigFromDraft, radarDraft,
radarSourceCandidates, recognizeRadar,
} from '../test-build/radar-editor.js';
const space = { id: 'floor', title: 'Floor', cellCm: 5, rooms: [
{ id: 'living', name: 'Living room', poly: [[0, 0], [1000, 0], [1000, 1000]] },
] };
const device = (overrides = {}) => ({
id: 'd1', name: 'Presence', space: 'floor', area: '', entities: [], allEntities: [],
bindingKind: 'device', bindingRef: 'dev1', marker: { id: 'd1', binding: 'device:dev1', space: 'floor' },
...overrides,
});
test('LD2450 exact role suffixes identify and prefill at most three slots', () => {
const radar = device({ allEntities: [
'sensor.office_target_1_x', 'sensor.office_target_1_y',
'sensor.office_target_2_x', 'sensor.office_target_2_y',
'sensor.office_target_count',
], model: 'HLK-LD2450', marker: {
id: 'd1', binding: 'device:dev1', space: 'floor', room_id: 'living',
} });
assert.deepEqual(recognizeRadar(radar, {}), {
eligible: true, profile: 'esphome_ld2450_v1', reason: 'ld2450',
});
const states = Object.fromEntries(radar.allEntities.map((entityId) => [entityId, { state: '0' }]));
const draft = radarDraft(radar, space, [500, 500], { states });
assert.ok(draft);
assert.equal(draft.enabled, false);
assert.equal(draft.xEntities[0], 'sensor.office_target_1_x');
assert.equal(draft.yEntities[1], 'sensor.office_target_2_y');
assert.equal(draft.xEntities.length, 3);
assert.equal(radarConfigFromDraft(draft, 5).mount.x, .5);
});
test('ordinary devices stay hidden until explicit manual declaration', () => {
const ordinary = device({ name: 'Kitchen light', allEntities: ['light.kitchen'] });
assert.equal(recognizeRadar(ordinary, {}).eligible, false);
assert.equal(radarDraft(ordinary, space, [50, 60], {}), null);
const draft = radarDraft(ordinary, space, [50, 60], {}, true);
assert.ok(draft);
assert.equal(draft.enabled, false);
assert.equal(draft.profile, 'presence_v1');
});
test('coordinate-looking entity names alone do not claim an LD2450 adapter', () => {
const ordinary = device({ allEntities: [
'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', () => {
const candidates = radarSourceCandidates(device({ allEntities: ['sensor.own'] }), {
states: { 'sensor.z': {}, 'sensor.other_x': {},
'binary_sensor.other_presence': {}, 'switch.no': {} },
});
assert.deepEqual(candidates.numeric, ['sensor.other_x', 'sensor.z']);
assert.deepEqual(candidates.binary, ['binary_sensor.other_presence']);
});
test('saved future stage siblings survive a stage-one edit', () => {
const original = {
version: 1, enabled: true, profile: 'cartesian_v1', room_id: 'living',
sources: { slots: [{ id: 'target_1', x_entity: 'sensor.x', y_entity: 'sensor.y', unit: 'cm' }] },
mount: { installation_id: 'install', x: 5, y: 6, heading_deg: 0 },
calibration: { method: 'manual', mirror: false, cell_cm: 5 },
zones: { local: [{ future: true }] }, future: { sentinel: 7 },
};
const d = device({ marker: { ...device().marker, radar: original } });
const draft = radarDraft(d, space, [100, 100], {});
const saved = radarConfigFromDraft({ ...draft, heading: '90' }, 5);
assert.equal(saved.future.sentinel, 7);
assert.deepEqual(saved.zones, original.zones);
assert.equal(saved.mount.heading_deg, 90);
assert.equal(saved.show_live, undefined);
assert.equal(saved.mount.x, original.mount.x);
});
test('an unrelated edit preserves accepted two-point calibration', () => {
const original = {
version: 1, enabled: true, show_live: false,
profile: 'cartesian_v1', room_id: 'living',
sources: { slots: [{ id: 'target_1', x_entity: 'sensor.x', y_entity: 'sensor.y', unit: 'cm' }] },
mount: { installation_id: 'install', x: .5, y: .5, heading_deg: 90 },
calibration: { method: 'two_point', mirror: true, cell_cm: 5,
refs: [
{ plan: { x: .5, y: .5 - 100 / 1200 }, local_cm: { x: 100, y: 0 } },
{ plan: { x: .5 + 100 / 1200, y: .5 }, local_cm: { x: 0, y: 100 } },
], rms_cm: 0 },
};
const configured = device({ marker: { ...device().marker, radar: original } });
const draft = radarDraft(configured, space, [0, 0], {});
const saved = radarConfigFromDraft({ ...draft, showLive: true }, 5);
assert.equal(saved.calibration.method, 'two_point');
assert.deepEqual(saved.calibration.refs, original.calibration.refs);
assert.equal(saved.show_live, undefined);
});
/**
* #567: гард `keepTwoPoint` сравнивал источники ТЕКСТОМ, а билдер сам меняет
* порядок ключей: `slots`/`occupancy_entity`/`count_entity` он удаляет и
* дописывает заново, а `availability_entity` остаётся на своём месте — то есть
* уезжает в начало. Конфиг, только что записанный этим же билдером, при
* следующем открытии сравнивался неравным, и радар молча терял `refs` и
* `rms_cm`, получая `method: manual`. Тест ведётся на радаре С
* `availability_entity`: без него старый код зелёный.
*/
const twoPointRadar = (sources) => ({
version: 1, enabled: true, profile: 'cartesian_v1', room_id: 'living',
sources,
mount: { installation_id: 'install', x: .5, y: .5, heading_deg: 90 },
calibration: {
method: 'two_point', mirror: true, cell_cm: 5,
refs: [
{ plan: { x: .5, y: .5 - 100 / 1200 }, local_cm: { x: 100, y: 0 } },
{ plan: { x: .5 + 100 / 1200, y: .5 }, local_cm: { x: 0, y: 100 } },
],
rms_cm: 0,
},
});
const savedFrom = (radar, patch = {}) => {
const configured = device({ marker: { ...device().marker, radar } });
const draft = radarDraft(configured, space, [0, 0], {});
return radarConfigFromDraft({ ...draft, ...patch }, 5);
};
test('#567 AC1: availability_entity не отменяет двухточечную калибровку', () => {
// Порядок ключей ровно тот, который пишет сам билдер: availability последним.
const radar = twoPointRadar({
slots: [{ id: 'target_1', x_entity: 'sensor.x', y_entity: 'sensor.y', unit: 'cm' }],
occupancy_entity: 'binary_sensor.occupancy',
availability_entity: 'binary_sensor.availability',
});
const saved = savedFrom(radar);
assert.equal(saved.calibration.method, 'two_point');
assert.deepEqual(saved.calibration.refs, radar.calibration.refs);
assert.equal(saved.calibration.rms_cm, 0);
// Источники сохранились по смыслу, порядок ключей значения не имеет.
assert.deepEqual(
Object.keys(saved.sources).sort(),
['availability_entity', 'occupancy_entity', 'slots'],
);
assert.equal(saved.sources.availability_entity, 'binary_sensor.availability');
// Проекция считается от этих трёх величин — правка их не касается.
assert.deepEqual(
[saved.mount.x, saved.mount.y, saved.mount.heading_deg, saved.calibration.cell_cm,
saved.calibration.mirror],
[.5, .5, 90, 5, true],
);
});
test('#567 AC2: смена сущности источника калибровку отменяет', () => {
const radar = twoPointRadar({
slots: [{ id: 'target_1', x_entity: 'sensor.x', y_entity: 'sensor.y', unit: 'cm' }],
availability_entity: 'binary_sensor.availability',
});
const saved = savedFrom(radar, { xEntities: ['sensor.other_x', '', ''] });
assert.equal(saved.calibration.method, 'manual');
assert.equal(saved.calibration.refs, undefined);
assert.equal(saved.calibration.rms_cm, undefined);
});
test('#567 AC3: порядок полей внутри слота не смысл, состав слотов — смысл', () => {
// Тот же слот, поля записаны в другом порядке: так выглядит запись, сделанная
// другой версией или пришедшая импортом.
const shuffled = twoPointRadar({
availability_entity: 'binary_sensor.availability',
slots: [{ unit: 'cm', y_entity: 'sensor.y', x_entity: 'sensor.x', id: 'target_1' }],
});
assert.equal(savedFrom(shuffled).calibration.method, 'two_point');
const extraSlot = twoPointRadar({
slots: [
{ id: 'target_1', x_entity: 'sensor.x', y_entity: 'sensor.y', unit: 'cm' },
{ id: 'target_2', x_entity: 'sensor.x2', y_entity: 'sensor.y2', unit: 'cm' },
],
availability_entity: 'binary_sensor.availability',
});
// Второй слот пропадает из черновика только вместе с сущностями, поэтому
// отнимаем именно их — это и есть «состав слотов изменился».
const saved = savedFrom(extraSlot, { xEntities: ['sensor.x', '', ''], yEntities: ['sensor.y', '', ''] });
assert.equal(saved.sources.slots.length, 1);
assert.equal(saved.calibration.method, 'manual');
// Обратная сторона того же контракта: позиция слота — это его target_N,
// поэтому перестановка сущностей между слотами меняет смысл, и калибровка
// обязана уйти. Отдельного мутанта здесь нет намеренно: сортировка массивов
// в канонизации поведение НЕ меняет (элементы остаются под своими индексами),
// такой «мутант» был бы записью, которая ничего не проверяет.
const swapped = savedFrom(extraSlot, {
xEntities: ['sensor.x2', 'sensor.x', ''], yEntities: ['sensor.y2', 'sensor.y', ''],
});
assert.equal(swapped.sources.slots[0].x_entity, 'sensor.x2');
assert.equal(swapped.calibration.method, 'manual');
});
test('polar conventions are explicit and round-trip through the editor', () => {
const original = {
version: 1, enabled: true, profile: 'polar_v1', room_id: 'living',
sources: { slots: [{ id: 'target_1', distance_entity: 'sensor.distance',
angle_entity: 'sensor.angle', unit: 'm', angle_unit: 'radians',
angle_zero: 'right', angle_clockwise: false }] },
mount: { installation_id: 'install', x: .2, y: .3, heading_deg: 15 },
calibration: { method: 'manual', mirror: false, cell_cm: 5 },
};
const configured = device({ marker: { ...device().marker, radar: original } });
const draft = radarDraft(configured, space, [0, 0], {});
assert.equal(draft.angleUnit, 'radians');
assert.equal(draft.angleZero, 'right');
assert.equal(draft.angleClockwise, false);
assert.deepEqual(radarConfigFromDraft(draft, 5).sources.slots[0], original.sources.slots[0]);
});
test('generic Cartesian axis mapping and per-slot gate round-trip exactly', () => {
const original = {
version: 1, enabled: true, profile: 'cartesian_v1', room_id: 'living',
sources: { slots: [{ id: 'target_1', x_entity: 'sensor.raw_a', y_entity: 'sensor.raw_b',
unit: 'ft', swap_xy: true, x_sign: -1, y_sign: -1,
presence_entity: 'binary_sensor.slot_1' }],
availability_entity: 'binary_sensor.radar_online' },
mount: { installation_id: 'install', x: .2, y: .3, heading_deg: 15 },
calibration: { method: 'manual', mirror: false, cell_cm: 5 },
};
const draft = radarDraft(device({ marker: { ...device().marker, radar: original } }), space, [0, 0], {});
const saved = radarConfigFromDraft(draft, 5);
assert.deepEqual(saved.sources.slots[0], original.sources.slots[0]);
assert.equal(saved.sources.availability_entity, 'binary_sensor.radar_online');
});
test('polar and range profiles persist every supported channel', () => {
const base = radarDraft(device(), space, [500, 500], {}, true);
const polar = radarConfigFromDraft({
...base, roomId: 'living', profile: 'polar_v1', distanceEntities: ['sensor.d1', 'sensor.d2'],
angleEntities: ['sensor.a1', 'sensor.a2'], slotPresenceEntities: ['', 'binary_sensor.p2'],
}, 5);
assert.equal(polar.sources.slots.length, 2);
assert.equal(polar.sources.slots[1].presence_entity, 'binary_sensor.p2');
const ranges = radarConfigFromDraft({
...base, roomId: 'living', profile: 'range_v1', rangeEntities: ['sensor.near', 'sensor.far'],
rangePresenceEntities: ['binary_sensor.moving', 'binary_sensor.still'],
}, 5);
assert.equal(ranges.sources.ranges.length, 2);
assert.equal(ranges.sources.ranges[1].presence_entity, 'binary_sensor.still');
});