Files
houseplan-card/test/ha-binding-status.test.mjs
T
2026-08-19 02:12:37 +03:00

306 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
acquireHaRegistries,
activeRegistryHass,
cacheHaBindingStatuses,
haRegistrySnapshot,
openingEntityAvailable,
renderOpeningEntityAvailable,
resolveHaBindingStatus,
} from '../test-build/ha-binding-status.js';
const full = (devices, entities) => ({
revision: 1,
authoritative: true,
access: 'full',
devices,
entities,
lastSuccess: 1,
});
const limited = (devices, entities) => ({
revision: 1,
authoritative: false,
access: 'limited',
devices,
entities,
lastSuccess: 0,
});
test('full registry distinguishes active, disabled and orphaned bindings', () => {
const devices = {
active: { id: 'active', disabled_by: null },
disabled: { id: 'disabled', disabled_by: 'user' },
empty: { id: 'empty', disabled_by: null },
};
const entities = {
'switch.active': { entity_id: 'switch.active', device_id: 'active', disabled_by: null },
'switch.disabled_parent': { entity_id: 'switch.disabled_parent', device_id: 'disabled', disabled_by: null },
'sensor.disabled': { entity_id: 'sensor.disabled', device_id: 'empty', disabled_by: 'integration' },
};
const hass = { devices, entities, states: { 'switch.active': { state: 'on' } } };
const snapshot = full(devices, entities);
assert.equal(resolveHaBindingStatus(hass, 'device:active', snapshot).kind, 'active');
assert.deepEqual(resolveHaBindingStatus(hass, 'device:disabled', snapshot), {
kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: ['switch.disabled_parent'],
});
assert.equal(resolveHaBindingStatus(hass, 'device:empty', snapshot).kind, 'ha_disabled');
assert.equal(resolveHaBindingStatus(hass, 'entity:sensor.disabled', snapshot).kind, 'ha_disabled');
assert.equal(resolveHaBindingStatus(hass, 'device:missing', snapshot).kind, 'orphaned');
assert.equal(resolveHaBindingStatus(hass, 'entity:sensor.missing', snapshot).kind, 'orphaned');
});
test('limited registry never guesses missing rows are disabled or orphaned', () => {
const hass = {
devices: {},
entities: {},
states: { 'switch.live_only': { state: 'off' } },
};
const snapshot = limited({}, {});
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.live_only', snapshot).kind, 'active');
assert.equal(resolveHaBindingStatus(hass, 'entity:switch.unknown', snapshot).kind, 'unverified');
assert.equal(resolveHaBindingStatus(hass, 'device:unknown', snapshot).kind, 'unverified');
assert.equal(openingEntityAvailable(hass, 'switch.live_only', snapshot), true);
assert.equal(openingEntityAvailable(hass, 'switch.unknown', snapshot), false);
});
test('authoritative registry accepts an exact live YAML entity without a registry row', () => {
const live = { states: { 'camera.xcme': { entity_id: 'camera.xcme', state: 'idle' } } };
assert.deepEqual(resolveHaBindingStatus(live, 'entity:camera.xcme', full({}, {})), {
kind: 'active', enabledEntityIds: ['camera.xcme'], allEntityIds: ['camera.xcme'],
});
assert.equal(
resolveHaBindingStatus({ states: {} }, 'entity:camera.xcme', full({}, {})).kind,
'orphaned',
);
const disabled = {
'camera.xcme': { entity_id: 'camera.xcme', disabled_by: 'user' },
};
assert.equal(
resolveHaBindingStatus(live, 'entity:camera.xcme', full({}, disabled)).kind,
'ha_disabled',
);
});
test('active projection removes stale states of disabled rows', () => {
const devices = {
d1: { id: 'd1', disabled_by: null },
d2: { id: 'd2', disabled_by: 'user' },
};
const entities = {
'switch.good': { entity_id: 'switch.good', device_id: 'd1', disabled_by: null },
'switch.disabled': { entity_id: 'switch.disabled', device_id: 'd1', disabled_by: 'user' },
'switch.parent_disabled': { entity_id: 'switch.parent_disabled', device_id: 'd2', disabled_by: null },
};
const states = Object.fromEntries(Object.keys(entities).map((eid) => [eid, { entity_id: eid, state: 'on' }]));
const projected = activeRegistryHass({ devices, entities, states }, full(devices, entities));
assert.deepEqual(Object.keys(projected.entities), ['switch.good']);
assert.deepEqual(Object.keys(projected.states), ['switch.good']);
});
test('authoritative active projection keeps live states without registry rows', () => {
const state = {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 35 },
};
const projected = activeRegistryHass(
{ devices: {}, entities: {}, states: { 'sun.sun': state } },
full({}, {}),
);
assert.equal(projected.states['sun.sun'], state);
assert.equal(projected.entities['sun.sun'], undefined);
});
test('authoritative projection removes a live entity whose registry parent is missing', () => {
const entities = {
'switch.orphan': {
entity_id: 'switch.orphan', device_id: 'missing-device', disabled_by: null,
},
};
const states = { 'switch.orphan': { entity_id: 'switch.orphan', state: 'on' } };
const projected = activeRegistryHass(
{ devices: {}, entities, states }, full({}, entities),
);
assert.equal(projected.entities['switch.orphan'], undefined);
assert.equal(projected.states['switch.orphan'], undefined);
});
test('limited active projection keeps a live entity when its parent row is unavailable', () => {
const entities = {
'switch.live': { entity_id: 'switch.live', device_id: 'not_exposed', disabled_by: null },
};
const states = { 'switch.live': { entity_id: 'switch.live', state: 'on' } };
const projected = activeRegistryHass({ devices: {}, entities, states }, limited({}, entities));
assert.ok(projected.entities['switch.live']);
assert.ok(projected.states['switch.live']);
});
test('live rows augment a stale full snapshot and remain immediately discoverable', async () => {
const connection = { subscribeEvents: async () => () => undefined };
const hass = {
connection,
devices: { old: { id: 'old', disabled_by: null } },
entities: {},
states: {},
callWS: async ({ type }) => type.includes('device_registry')
? [{ id: 'old', disabled_by: null }] : [],
};
let ready;
const loaded = new Promise((resolve) => { ready = resolve; });
const release = acquireHaRegistries(hass, ready);
await loaded;
hass.devices = { ...hass.devices, fresh: { id: 'fresh', disabled_by: null } };
hass.entities = {
'switch.fresh': { entity_id: 'switch.fresh', device_id: 'fresh', disabled_by: null },
};
hass.states = { 'switch.fresh': { entity_id: 'switch.fresh', state: 'off' } };
const snapshot = haRegistrySnapshot(hass);
assert.equal(snapshot.authoritative, true);
assert.ok(snapshot.devices.fresh);
assert.ok(snapshot.entities['switch.fresh']);
assert.equal(resolveHaBindingStatus(hass, 'device:fresh', snapshot).kind, 'active');
release();
});
test('limited live rows cannot override the last authoritative disabled result', () => {
const previousStorage = globalThis.localStorage;
globalThis.localStorage = {
getItem: () => '{}',
setItem: () => undefined,
};
cacheHaBindingStatuses(new Map([
['device:cached-disabled', {
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
}],
['entity:switch.cached', {
kind: 'ha_disabled', reason: 'entity', enabledEntityIds: [], allEntityIds: ['switch.cached'],
}],
]));
const devices = { 'cached-disabled': { id: 'cached-disabled', disabled_by: null } };
const entities = {
'switch.cached': { entity_id: 'switch.cached', device_id: 'cached-disabled', disabled_by: null },
};
const hass = { devices, entities, states: { 'switch.cached': { state: 'on' } } };
assert.deepEqual(resolveHaBindingStatus(hass, 'device:cached-disabled', limited(devices, entities)), {
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
});
assert.equal(openingEntityAvailable(hass, 'switch.cached', limited(devices, entities)), false);
globalThis.localStorage = previousStorage;
});
test('entity with a missing parent reports device_missing', () => {
const entities = {
'sensor.orphan': { entity_id: 'sensor.orphan', device_id: 'gone', disabled_by: null },
};
assert.equal(
resolveHaBindingStatus({ devices: {}, entities, states: {} }, 'entity:sensor.orphan', full({}, entities)).reason,
'device_missing',
);
});
test('opening entity availability ignores marker lifecycle and follows exact HA status', () => {
const devices = {
active: { id: 'active', disabled_by: null },
disabled: { id: 'disabled', disabled_by: 'user' },
};
const entities = {
'binary_sensor.door': {
entity_id: 'binary_sensor.door', device_id: 'active', disabled_by: null,
},
'lock.front': { entity_id: 'lock.front', device_id: 'active', disabled_by: null },
'lock.disabled_entity': {
entity_id: 'lock.disabled_entity', device_id: 'active', disabled_by: 'user',
},
'lock.disabled_parent': {
entity_id: 'lock.disabled_parent', device_id: 'disabled', disabled_by: null,
},
'lock.orphan': { entity_id: 'lock.orphan', device_id: 'missing', disabled_by: null },
};
const hass = {
devices, entities,
states: {
'binary_sensor.door': { state: 'unavailable' },
'lock.front': { state: 'locked' },
'lock.disabled_entity': { state: 'locked' },
'lock.disabled_parent': { state: 'locked' },
'lock.orphan': { state: 'locked' },
},
};
const snapshot = full(devices, entities);
// Marker lifecycle is intentionally not an input: the opening is an exact
// independent consumer before deletion, after either tombstone and re-add.
for (const _markerLifecycle of ['present', 'entity-tombstone', 'device-tombstone', 're-added']) {
assert.equal(openingEntityAvailable(hass, 'binary_sensor.door', snapshot), true);
assert.equal(openingEntityAvailable(hass, 'lock.front', snapshot), true);
}
assert.equal(openingEntityAvailable(hass, 'lock.disabled_entity', snapshot), false);
assert.equal(openingEntityAvailable(hass, 'lock.disabled_parent', snapshot), false);
assert.equal(openingEntityAvailable(hass, 'lock.orphan', snapshot), false);
assert.equal(openingEntityAvailable(hass, 'lock.missing', snapshot), false);
assert.equal(openingEntityAvailable(hass, null, snapshot), false);
});
test('opening render availability trusts one frozen active projection', () => {
const devices = {
active: { id: 'active', disabled_by: null },
disabled: { id: 'disabled', disabled_by: 'user' },
};
const entities = {
'binary_sensor.door': {
entity_id: 'binary_sensor.door', device_id: 'active', disabled_by: null,
},
'lock.no_state': { entity_id: 'lock.no_state', device_id: 'active', disabled_by: null },
'lock.disabled_entity': {
entity_id: 'lock.disabled_entity', device_id: 'active', disabled_by: 'user',
},
'lock.disabled_parent': {
entity_id: 'lock.disabled_parent', device_id: 'disabled', disabled_by: null,
},
'lock.orphan': {
entity_id: 'lock.orphan', device_id: 'missing-device', disabled_by: null,
},
};
const states = {
'binary_sensor.door': { entity_id: 'binary_sensor.door', state: 'unknown' },
'binary_sensor.yaml_only': { entity_id: 'binary_sensor.yaml_only', state: 'on' },
'lock.yaml_unknown': { entity_id: 'lock.yaml_unknown', state: 'unavailable' },
'lock.disabled_entity': { entity_id: 'lock.disabled_entity', state: 'locked' },
'lock.disabled_parent': { entity_id: 'lock.disabled_parent', state: 'locked' },
'lock.orphan': { entity_id: 'lock.orphan', state: 'locked' },
};
const frame = activeRegistryHass({ devices, entities, states }, full(devices, entities));
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.door'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.no_state'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.yaml_unknown'), true,
'unknown/unavailable remains an existing exact reference, not an open state');
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_entity'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.disabled_parent'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.orphan'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.missing'), false);
assert.equal(renderOpeningEntityAvailable(frame, ''), false);
const limitedFrame = activeRegistryHass({
devices: {}, entities: {},
states: { 'binary_sensor.limited_yaml': { state: 'off' } },
}, limited({}, {}));
assert.equal(renderOpeningEntityAvailable(limitedFrame, 'binary_sensor.limited_yaml'), true);
const markerTombstoneIsNotAnInput = {
...frame,
markers: [{ binding: 'entity:binary_sensor.yaml_only', removed: true }],
};
assert.equal(
renderOpeningEntityAvailable(markerTombstoneIsNotAnInput, 'binary_sensor.yaml_only'),
true,
);
});