mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
376 lines
15 KiB
JavaScript
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');
|
|
});
|