Files
houseplan-card/test/device-area-relocation.test.mjs
Codex d4dd027b0a build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 15:23:40 +03:00

376 lines
15 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAreaRelocationResolution,
MARKER_AREA_SNAPSHOT_LIMIT,
markerAreaSnapshotOf,
removeMarkerAreaSnapshots,
registryFollowingBinding,
resolveAreaSnapshotCleanup,
resolveDeviceAreaRelocations,
} from '../test-build/device-area-relocation.js';
const room = (id, area, x) => ({
id, area, name: id,
poly: [[x, 0], [x + 400, 0], [x + 400, 400], [x, 400]],
});
const model = [{
id: 'floor', title: 'Floor', vb: [0, 0, 1000, 1000], bg: null,
rooms: [room('left', 'area-a', 0), room('right', 'area-b', 500)],
wall_segments: [], room_drafts: [], partitions: [], wall_columns: [],
}];
const device = (overrides = {}) => ({
id: 'device-1', name: 'Lamp', model: '', area: 'area-b', space: 'floor',
icon: 'mdi:lightbulb', entities: [], bindingKind: 'device', bindingRef: 'device-1',
bindingStatus: { kind: 'active', enabledEntityIds: [], allEntityIds: [] },
...overrides,
});
const resolve = (overrides = {}) => resolveDeviceAreaRelocations({
devices: [device()], model, layout: {}, snapshot: {}, authoritative: true,
...overrides,
});
const cleanup = (overrides = {}) => resolveAreaSnapshotCleanup({
snapshot: {}, authoritative: true, revision: 1,
registryDevices: { live: {} }, registryEntities: { 'sensor.live': {} },
liveStates: {}, markers: [], previousCandidates: new Map(),
...overrides,
});
test('known Area transition relocates even a same-space dragged marker', () => {
const result = resolve({
layout: { 'device-1': { s: 'floor', x: 0.2, y: 0.2 } },
snapshot: { 'device-1': { binding: 'device:device-1', area: 'area-a' } },
});
assert.deepEqual([...result.relocateIds], ['device-1']);
assert.equal(result.decisions[0].reason, 'area-changed');
assert.deepEqual(
applyAreaRelocationResolution({}, result, result.relocateIds),
{ 'device-1': { binding: 'device:device-1', area: 'area-b' } },
);
});
test('initial backfill relocates only a provably stale room or space', () => {
const staleRoom = resolve({ layout: { 'device-1': { s: 'floor', x: 0.2, y: 0.2 } } });
assert.equal(staleRoom.decisions[0].reason, 'backfill-stale-room');
assert.equal(staleRoom.relocateIds.has('device-1'), true);
const sameRoom = resolve({ layout: { 'device-1': { s: 'floor', x: 0.7, y: 0.2 } } });
assert.equal(sameRoom.decisions[0].reason, 'backfill-same-room');
assert.equal(sameRoom.relocateIds.size, 0);
const crossSpace = resolve({ layout: { 'device-1': { s: 'other', x: 0.2, y: 0.2 } } });
assert.equal(crossSpace.decisions[0].reason, 'backfill-cross-space');
assert.equal(crossSpace.relocateIds.has('device-1'), true);
});
test('boundary, outside and area-less source are safe ambiguous baselines', () => {
const areaLessRoom = {
...room('storage', undefined, 0),
poly: [[0, 500], [400, 500], [400, 900], [0, 900]],
};
const modelWithAreaLess = [{
...model[0],
rooms: [...model[0].rooms, areaLessRoom],
}];
for (const placement of [
{ s: 'floor', x: 0.4, y: 0.2 },
{ s: 'floor', x: 0.95, y: 0.8 },
{ x: 0.2, y: 0.2 },
]) {
const result = resolve({ layout: { 'device-1': placement } });
assert.equal(result.decisions[0].reason, 'backfill-ambiguous');
assert.equal(result.relocateIds.size, 0);
}
const areaLess = resolve({
model: modelWithAreaLess,
layout: { 'device-1': { s: 'floor', x: 0.2, y: 0.7 } },
});
assert.equal(areaLess.decisions[0].reason, 'backfill-ambiguous');
assert.equal(areaLess.relocateIds.size, 0);
});
test('explicit placement, virtual markers and composite groups never follow registry Area', () => {
const explicitArea = device({ marker: {
id: 'device-1', binding: 'device:device-1', area: 'area-a',
} });
const explicitRoom = device({ marker: {
id: 'device-1', binding: 'device:device-1', area: null, space: 'floor', room_id: 'left',
} });
const composite = device({
id: 'lg_light.group', bindingKind: 'entity', bindingRef: 'light.group', marker: undefined,
});
const directEntity = device({
id: 'entity-marker', bindingKind: 'entity', bindingRef: 'sensor.one',
marker: { id: 'entity-marker', binding: 'entity:sensor.one' },
});
const removed = device({ marker: {
id: 'device-1', binding: 'device:device-1', removed: true,
} });
const unverified = device({ bindingStatus: {
kind: 'unverified', enabledEntityIds: [], allEntityIds: [],
} });
const disabled = device({ bindingStatus: {
kind: 'ha_disabled', enabledEntityIds: [], allEntityIds: [],
} });
assert.equal(registryFollowingBinding(explicitArea), null);
assert.equal(registryFollowingBinding(explicitRoom), null);
assert.equal(registryFollowingBinding(composite), null);
assert.equal(registryFollowingBinding(directEntity), 'entity:sensor.one');
assert.equal(registryFollowingBinding(removed), null);
assert.equal(registryFollowingBinding(unverified), null);
assert.equal(registryFollowingBinding(disabled), 'device:device-1');
});
test('direct saved entity markers relocate, while ineligible entries are cleaned', () => {
const directEntity = device({
id: 'entity-marker', bindingKind: 'entity', bindingRef: 'sensor.one',
marker: { id: 'entity-marker', binding: 'entity:sensor.one' },
});
const result = resolve({
devices: [directEntity],
layout: { 'entity-marker': { s: 'floor', x: 0.2, y: 0.2 } },
snapshot: { 'entity-marker': { binding: 'entity:sensor.one', area: 'area-a' } },
});
assert.equal(result.relocateIds.has('entity-marker'), true);
const cleanup = resolve({
devices: [device({ marker: {
id: 'device-1', binding: 'device:device-1', removed: true,
} })],
snapshot: { 'device-1': { binding: 'device:device-1', area: 'area-a' } },
});
assert.equal(cleanup.decisions[0].removeSnapshot, true);
assert.deepEqual(applyAreaRelocationResolution(
{ 'device-1': { binding: 'device:device-1', area: 'area-a' } }, cleanup,
), {});
});
test('a new marker without saved layout establishes a silent baseline', () => {
const result = resolve();
assert.equal(result.decisions[0].reason, 'new-without-layout');
assert.equal(result.relocateIds.size, 0);
assert.deepEqual(applyAreaRelocationResolution({}, result), {
'device-1': { binding: 'device:device-1', area: 'area-b' },
});
});
test('rebind establishes a new baseline instead of inventing an Area transition', () => {
const rebound = device({ bindingRef: 'device-2' });
const previous = { 'device-1': { binding: 'device:device-1', area: 'area-a' } };
const result = resolve({
devices: [rebound],
snapshot: previous,
});
assert.equal(result.relocateIds.size, 0);
assert.equal(result.decisions[0].reason, 'new-without-layout');
assert.deepEqual(applyAreaRelocationResolution(previous, result), {
'device-1': { binding: 'device:device-2', area: 'area-b' },
});
});
test('unresolved duplicate Area and non-authoritative registry are no-ops', () => {
const duplicate = [{ ...model[0], rooms: [...model[0].rooms, room('duplicate', 'area-b', 0)] }];
assert.equal(resolve({ model: duplicate }).decisions[0].reason, 'target-unresolved');
assert.deepEqual(resolve({ authoritative: false }), { decisions: [], relocateIds: new Set() });
});
test('orphan cleanup uses full registry evidence instead of the presentation roster', () => {
const snapshot = {
orphan: { binding: 'device:orphan', area: 'area-a' },
'canonical-device-id': { binding: 'device:device-row', area: 'area-a' },
};
const live = cleanup({
snapshot,
registryDevices: { orphan: {}, 'device-row': {} },
});
assert.deepEqual(live.removeIds, new Set());
assert.deepEqual(live.candidates, new Map());
assert.equal(live.needsConfirmationRefresh, false);
const result = resolve({ devices: [], snapshot, cleanupSnapshotIds: live.removeIds });
assert.deepEqual(applyAreaRelocationResolution(snapshot, result), {
orphan: snapshot.orphan,
'canonical-device-id': snapshot['canonical-device-id'],
});
});
test('empty registry namespaces never confirm destructive snapshot cleanup', () => {
const snapshot = {
dev: { binding: 'device:gone', area: 'area-a' },
ent: { binding: 'entity:sensor.gone', area: 'area-a' },
};
const previousCandidates = new Map([
['device:gone', 1], ['entity:sensor.gone', 1],
]);
const result = cleanup({
snapshot, revision: 2, registryDevices: {}, registryEntities: {},
previousCandidates,
});
assert.deepEqual(result.removeIds, new Set());
assert.deepEqual(result.candidates, previousCandidates);
assert.equal(result.needsConfirmationRefresh, false);
});
test('exact live entity state preserves registry-less snapshot provenance', () => {
for (const state of ['on', 'unavailable', 'unknown']) {
const snapshot = { ent: { binding: 'entity:sensor.yaml', area: 'area-a' } };
const result = cleanup({
snapshot,
registryEntities: { 'sensor.other': {} },
liveStates: { 'sensor.yaml': { state } },
previousCandidates: new Map([['entity:sensor.yaml', 0]]),
});
assert.deepEqual(result.removeIds, new Set(), state);
assert.deepEqual(result.candidates, new Map(), state);
}
});
test('a live saved marker preserves matching id or binding but a tombstone does not', () => {
const snapshot = {
byId: { binding: 'device:old-binding', area: 'area-a' },
byBinding: { binding: 'device:marker-binding', area: 'area-a' },
removed: { binding: 'device:removed', area: 'area-a' },
};
const first = cleanup({
snapshot,
markers: [
{ id: 'byId', binding: 'device:new-binding' },
{ id: 'another-id', binding: 'device:marker-binding' },
{ id: 'removed', binding: 'device:removed', removed: true },
],
});
assert.deepEqual(first.candidates, new Map([['device:removed', 1]]));
const second = cleanup({
snapshot, revision: 2, previousCandidates: first.candidates,
markers: [
{ id: 'byId', binding: 'device:new-binding' },
{ id: 'another-id', binding: 'device:marker-binding' },
{ id: 'removed', binding: 'device:removed', removed: true },
],
});
assert.deepEqual(second.removeIds, new Set(['removed']));
});
test('snapshot cleanup requires two distinct non-empty authoritative revisions', () => {
const snapshot = {
orphan: { binding: 'device:orphan', area: 'area-a' },
keep: { binding: 'device:keep', area: 'area-a' },
};
const first = cleanup({ snapshot, registryDevices: { keep: {} }, revision: 7 });
assert.deepEqual(first.removeIds, new Set());
assert.deepEqual(first.candidates, new Map([['device:orphan', 7]]));
assert.equal(first.needsConfirmationRefresh, true);
const repeated = cleanup({
snapshot, registryDevices: { keep: {} }, revision: 7,
previousCandidates: first.candidates,
});
assert.deepEqual(repeated.removeIds, new Set());
assert.equal(repeated.needsConfirmationRefresh, false);
const confirmed = cleanup({
snapshot, registryDevices: { keep: {} }, revision: 8,
previousCandidates: repeated.candidates,
});
assert.deepEqual(confirmed.removeIds, new Set(['orphan']));
const resolution = resolve({
devices: [], snapshot, cleanupSnapshotIds: confirmed.removeIds,
});
assert.deepEqual(applyAreaRelocationResolution(snapshot, resolution), {
keep: snapshot.keep,
});
});
test('binding recovery clears absence evidence and a later loss starts over', () => {
const snapshot = { orphan: { binding: 'device:orphan', area: 'area-a' } };
const first = cleanup({ snapshot, revision: 10 });
const recovered = cleanup({
snapshot, revision: 11, registryDevices: { live: {}, orphan: {} },
previousCandidates: first.candidates,
});
assert.deepEqual(recovered.candidates, new Map());
assert.deepEqual(recovered.removeIds, new Set());
const lostAgain = cleanup({
snapshot, revision: 12, previousCandidates: recovered.candidates,
});
assert.deepEqual(lostAgain.candidates, new Map([['device:orphan', 12]]));
assert.deepEqual(lostAgain.removeIds, new Set());
assert.equal(lostAgain.needsConfirmationRefresh, true);
});
test('#434 cleanup drops a previous candidate absent from the current snapshot', () => {
const snapshot = { keep: { binding: 'device:keep', area: 'area-a' } };
const result = cleanup({
snapshot,
revision: 31,
previousCandidates: new Map([
['device:removed-from-snapshot', 30],
['device:keep', 30],
]),
});
assert.equal(result.candidates.has('device:removed-from-snapshot'), false,
'valid finite evidence cannot outlive the snapshot binding it belongs to');
assert.equal(result.candidates.has('device:keep'), true);
});
test('limited frames and runtime reset do not confirm an earlier absence', () => {
const snapshot = { orphan: { binding: 'device:orphan', area: 'area-a' } };
const first = cleanup({ snapshot, revision: 20 });
const limited = cleanup({
snapshot, revision: 21, authoritative: false,
previousCandidates: first.candidates,
});
assert.deepEqual(limited.removeIds, new Set());
assert.deepEqual(limited.candidates, first.candidates);
const remounted = cleanup({ snapshot, revision: 22, previousCandidates: new Map() });
assert.deepEqual(remounted.removeIds, new Set());
assert.deepEqual(remounted.candidates, new Map([['device:orphan', 22]]));
});
test('non-authoritative registry preserves orphan snapshots', () => {
const snapshot = { orphan: { binding: 'device:orphan', area: 'area-a' } };
const result = resolve({ devices: [], snapshot, authoritative: false });
assert.deepEqual(result, { decisions: [], relocateIds: new Set() });
assert.deepEqual(applyAreaRelocationResolution(snapshot, result), snapshot);
});
test('failed delete does not advance relocation provenance', () => {
const previous = { 'device-1': { binding: 'device:device-1', area: 'area-a' } };
const result = resolve({ snapshot: previous });
assert.deepEqual(applyAreaRelocationResolution(previous, result), previous);
});
test('defensive snapshot reader drops malformed entries without poisoning valid ones', () => {
assert.deepEqual(markerAreaSnapshotOf({
good: { binding: 'entity:sensor.good', area: 'kitchen' },
bad: { binding: 'virtual', area: 'kitchen' },
empty: { binding: 'device:x', area: '' },
}), { good: { binding: 'entity:sensor.good', area: 'kitchen' } });
});
test('defensive snapshot reader keeps the newest entries when over its limit', () => {
const source = Object.fromEntries(Array.from(
{ length: MARKER_AREA_SNAPSHOT_LIMIT + 2 },
(_, index) => [`entry-${index}`, { binding: `device:${index}`, area: 'area-a' }],
));
const snapshot = markerAreaSnapshotOf(source);
assert.equal(Object.keys(snapshot).length, MARKER_AREA_SNAPSHOT_LIMIT);
assert.equal(Object.hasOwn(snapshot, 'entry-0'), false);
assert.equal(Object.hasOwn(snapshot, 'entry-1'), false);
assert.deepEqual(snapshot['entry-2'], { binding: 'device:2', area: 'area-a' });
assert.deepEqual(snapshot[`entry-${MARKER_AREA_SNAPSHOT_LIMIT + 1}`], {
binding: `device:${MARKER_AREA_SNAPSHOT_LIMIT + 1}`, area: 'area-a',
});
});
test('marker deletion and rebind cleanup remove only their own lifecycle entries', () => {
const source = {
keep: { binding: 'device:keep', area: 'living' },
deleted: { binding: 'device:deleted', area: 'hall' },
rebound: { binding: 'entity:sensor.old', area: 'kitchen' },
};
assert.deepEqual(removeMarkerAreaSnapshots(source, ['deleted', 'rebound']), {
keep: source.keep,
});
assert.equal(Object.hasOwn(source, 'deleted'), true, 'helper must not mutate config input');
});