mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: keep opening HA references after marker deletion
Issue: #104 User-Visible: yes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user