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'); });