fix: keep opening HA references after marker deletion

Issue: #104
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-13 13:11:57 +03:00
parent 9e176aa1d7
commit ee2357b914
14 changed files with 368 additions and 43 deletions
+73 -3
View File
@@ -6,6 +6,8 @@ import {
activeRegistryHass,
cacheHaBindingStatuses,
haRegistrySnapshot,
openingEntityAvailable,
renderOpeningEntityAvailable,
resolveHaBindingStatus,
} from '../test-build/ha-binding-status.js';
@@ -61,6 +63,8 @@ test('limited registry never guesses missing rows are disabled or orphaned', ()
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', () => {
@@ -170,9 +174,14 @@ test('limited live rows cannot override the last authoritative disabled result',
getItem: () => '{}',
setItem: () => undefined,
};
cacheHaBindingStatuses(new Map([['device:cached-disabled', {
kind: 'ha_disabled', reason: 'all_entities', enabledEntityIds: [], allEntityIds: ['switch.cached'],
}]]));
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 },
@@ -181,6 +190,7 @@ test('limited live rows cannot override the last authoritative disabled result',
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;
});
@@ -193,3 +203,63 @@ test('entity with a missing parent reports device_missing', () => {
'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 requires one frozen registry-backed frame', () => {
const frame = {
entities: {
'binary_sensor.door': { entity_id: 'binary_sensor.door' },
'lock.no_state': { entity_id: 'lock.no_state' },
},
states: {
'binary_sensor.door': { state: 'unknown' },
'binary_sensor.yaml_only': { state: 'on' },
},
};
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.door'), true);
assert.equal(renderOpeningEntityAvailable(frame, 'lock.no_state'), false);
assert.equal(renderOpeningEntityAvailable(frame, 'binary_sensor.yaml_only'), false, '#117 owns YAML parity');
assert.equal(renderOpeningEntityAvailable(frame, ''), false);
});
+42 -1
View File
@@ -63,7 +63,7 @@ test('RenderDeviceSnapshot keeps immutable facts and excludes live HA capabiliti
});
const methodBody = (source, name) => {
const start = source.indexOf(`private ${name}(`);
const start = source.search(new RegExp(`private\\s+(?:async\\s+)?${name}\\(`));
assert.notEqual(start, -1, `${name} exists`);
const tail = source.slice(start + 1);
const next = tail.search(/\n (?:private|protected|public)\s/);
@@ -95,3 +95,44 @@ test('the card gates snapshot positions on the render model', () => {
'the previous unconditional position path must not return',
);
});
test('opening references use their own availability policy without weakening plan tombstones', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
for (const name of [
'_contactCandidates', '_lockCandidates', '_openingAmt', '_renderOpenings',
'_renderOpeningLocks', '_renderOpeningInfoCard', '_lockAction',
]) {
assert.match(
methodBody(source, name),
/_openingEntityAvailable|_renderOpeningEntityAvailable/,
`${name} uses the explicit opening-reference policy`,
);
}
const planAvailability = methodBody(source, '_planEntityAvailable');
const renderAvailability = methodBody(source, '_renderEntityAvailable');
assert.match(planAvailability, /isRemovedPlanEntity/);
assert.match(renderAvailability, /isRemovedPlanEntity/);
assert.doesNotMatch(planAvailability, /openingEntityAvailable/);
assert.doesNotMatch(renderAvailability, /renderOpeningEntityAvailable/);
});
test('lock actuation remains guarded inside the one sanctioned opening-card method', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const action = methodBody(source, '_lockAction');
const guardAt = action.indexOf('_openingEntityAvailable(entityId)');
const confirmAt = action.indexOf('confirm(');
const serviceAt = action.indexOf("callService?.('lock'");
assert.ok(guardAt >= 0 && guardAt < confirmAt && confirmAt < serviceAt);
assert.equal(source.match(/callService\?\.\('lock'/g)?.length, 1);
assert.equal(source.match(/this\._lockAction\(/g)?.length, 1, 'only the opening card button calls it');
});
test('marker delete/re-add and opening save remain separate config transactions', () => {
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
const saveOpening = methodBody(source, '_saveOpening');
const saveMarker = methodBody(source, '_saveMarker');
assert.match(saveOpening, /sp\.openings/);
assert.doesNotMatch(saveOpening, /cfg\.markers|this\._markers/);
assert.match(saveMarker, /cfg\.markers/);
assert.doesNotMatch(saveMarker, /\.openings/);
});