Files
houseplan-card/demo/smoke_opening_binding.mjs
2026-08-20 15:30:57 +03:00

189 lines
8.3 KiB
JavaScript

// #104 pre-beta smoke: an opening owns exact contact/lock references
// independently of the standalone marker tombstone. This file is added during
// implementation but intentionally runs only in the pre-beta smoke gate.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const settle = async () => {
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const openingRefs = () => c._serverCfg.spaces
.find((space) => space.id === 'f1').openings
.map(({ id, contact, lock }) => ({ id, contact, lock }));
const space = c._serverCfg.spaces.find((item) => item.id === 'f1');
space.openings = [
{
id: 'hp104-a', type: 'door', x: 0.22, y: 0.14, angle: 0, length: 0.09,
contact: 'binary_sensor.window', lock: 'lock.front_door',
},
{
id: 'hp104-b', type: 'door', x: 0.40, y: 0.14, angle: 0, length: 0.09,
contact: 'binary_sensor.window', lock: 'lock.front_door',
},
];
const refsBefore = JSON.stringify(openingRefs());
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'window-gone', binding: 'entity:binary_sensor.window', removed: true, hidden: true },
{ id: 'lock-gone', binding: 'device:d_lock', removed: true, hidden: true },
],
};
c._regSignature = '';
c._maybeRebuildDevices();
c.hass = { ...c.hass };
await settle();
const contactValues = c._contactCandidates().map((item) => item.value);
const lockValues = c._lockCandidates().map((item) => item.value);
const openings = c._openingsR;
const lockBadge = sr().querySelector('.oplock');
const originalLockClass = lockBadge?.className || '';
const lockVisual = (theme, state) => {
lockBadge.className = `oplock theme-${theme} ${state}`;
const shell = lockBadge.querySelector('.oplock-shell');
const core = lockBadge.querySelector('.oplock-core');
const icon = lockBadge.querySelector('ha-icon');
const shellStyle = getComputedStyle(shell);
const coreStyle = getComputedStyle(core);
const shellRect = shell.getBoundingClientRect();
const coreRect = core.getBoundingClientRect();
const iconRect = icon.getBoundingClientRect();
return {
shell: shellStyle.borderColor,
core: coreStyle.backgroundColor,
glyph: coreStyle.color,
shellCoreRatio: shellRect.width / coreRect.width,
glyphCoreRatio: iconRect.width / coreRect.width,
footprint: shellRect.width,
};
};
const lockVisualMatrix = Object.fromEntries(['light', 'dark'].map((theme) => [theme, {
locked: lockVisual(theme, 'locked'),
unlocked: lockVisual(theme, 'unlocked'),
unknown: lockVisual(theme, 'unknown'),
}]));
if (lockBadge) lockBadge.className = originalLockClass;
const result = {
entityTombstoneCandidate: contactValues.includes('binary_sensor.window'),
deviceTombstoneCandidate: lockValues.includes('lock.front_door'),
markerContributionsStaySuppressed:
!c._planEntityAvailable('binary_sensor.window')
&& !c._planEntityAvailable('lock.front_door'),
exactOpeningReferencesStayActive:
c._openingEntityAvailable('binary_sensor.window')
&& c._openingEntityAvailable('lock.front_door'),
sharedContactDrivesBoth: openings.length === 2
&& openings.every((opening) => c._openingAmt(opening) === 1),
sharedLockRendersTwice: sr().querySelectorAll('.oplock').length === 2,
openingLockUsesPackageGeometryAndStates:
Object.values(lockVisualMatrix).every((theme) => Object.values(theme).every((state) =>
Math.abs(state.shellCoreRatio - 1.26875) < 0.03
&& Math.abs(state.glyphCoreRatio - 0.55) < 0.03
&& state.footprint > 0))
&& lockVisualMatrix.light.locked.core === 'rgb(102, 209, 122)'
&& lockVisualMatrix.light.locked.shell === 'rgb(102, 209, 122)'
&& lockVisualMatrix.light.locked.glyph === 'rgb(255, 255, 255)'
&& lockVisualMatrix.dark.locked.core === 'rgb(102, 209, 122)'
&& lockVisualMatrix.dark.locked.shell === 'rgb(102, 209, 122)'
&& lockVisualMatrix.dark.locked.glyph === 'rgb(37, 37, 37)'
&& lockVisualMatrix.light.unlocked.core === 'rgb(240, 65, 12)'
&& lockVisualMatrix.light.unlocked.shell === 'rgb(240, 65, 12)'
&& lockVisualMatrix.light.unlocked.glyph === 'rgb(255, 255, 255)'
&& lockVisualMatrix.dark.unlocked.core === 'rgb(240, 65, 12)'
&& lockVisualMatrix.dark.unlocked.shell === 'rgb(240, 65, 12)'
&& lockVisualMatrix.dark.unlocked.glyph === 'rgb(37, 37, 37)'
&& lockVisualMatrix.light.unknown.core === 'rgb(255, 255, 255)'
&& lockVisualMatrix.light.unknown.shell === 'rgb(188, 188, 188)'
&& lockVisualMatrix.dark.unknown.core === 'rgb(37, 37, 37)'
&& lockVisualMatrix.dark.unknown.shell === 'rgba(37, 37, 37, 0.75)',
deletionKeepsOpeningFields: JSON.stringify(openingRefs()) === refsBefore,
};
// The opening info card remains the one deliberate lock surface.
c._openingInfo = openings[0];
await settle();
result.infoHasContactAndLock = sr().querySelectorAll('.oprow').length === 2;
result.infoHasOneLockAction = sr().querySelectorAll('.lockact').length === 1;
const calls = [];
c.hass = {
...c.hass,
callService: async (domain, service, data) => calls.push({ domain, service, data }),
};
c._lockAction('lock.front_door', 'lock');
result.tombstonedMarkerDoesNotBlockExplicitLock = calls.length === 1
&& calls[0].domain === 'lock' && calls[0].service === 'lock';
// Re-adding replaces only the marker record; exact opening fields remain
// byte-for-byte the same and therefore cannot be duplicated or retargeted.
c._serverCfg = {
...c._serverCfg,
markers: [
{ id: 'window-back', binding: 'entity:binary_sensor.window', hidden: false },
{ id: 'lock-back', binding: 'device:d_lock', hidden: false },
],
};
c._regSignature = '';
c._maybeRebuildDevices();
await settle();
result.readdKeepsOpeningFields = JSON.stringify(openingRefs()) === refsBefore;
// Active registry rows with unknown transport state keep the reference but
// expose no lock action; disabled and orphaned exact rows fail closed.
c.hass = {
...c.hass,
devices: {
...c.hass.devices,
hp104_disabled_parent: { id: 'hp104_disabled_parent', disabled_by: 'user' },
},
entities: {
...c.hass.entities,
'lock.hp104_disabled': {
entity_id: 'lock.hp104_disabled', device_id: null, disabled_by: 'user',
},
'lock.hp104_disabled_parent': {
entity_id: 'lock.hp104_disabled_parent', device_id: 'hp104_disabled_parent', disabled_by: null,
},
'lock.hp104_orphan': {
entity_id: 'lock.hp104_orphan', device_id: 'hp104_missing_parent', disabled_by: null,
},
},
states: {
...c.hass.states,
'lock.front_door': { ...c.hass.states['lock.front_door'], state: 'unavailable' },
'lock.hp104_disabled': { entity_id: 'lock.hp104_disabled', state: 'locked', attributes: {} },
'lock.hp104_disabled_parent': {
entity_id: 'lock.hp104_disabled_parent', state: 'locked', attributes: {},
},
'lock.hp104_orphan': { entity_id: 'lock.hp104_orphan', state: 'locked', attributes: {} },
},
callService: async (domain, service, data) => calls.push({ domain, service, data }),
};
await settle();
c._openingInfo = c._openingsR[0];
await settle();
const unavailableCandidates = c._lockCandidates().map((item) => item.value);
result.unavailableRemainsCandidate = unavailableCandidates.includes('lock.front_door');
result.unavailableShowsUnknownWithoutAction = sr().querySelector('.oplock.unknown') != null
&& sr().querySelector('.lockact') == null;
result.disabledAndOrphanedAreRejected = !unavailableCandidates.includes('lock.hp104_disabled')
&& !unavailableCandidates.includes('lock.hp104_disabled_parent')
&& !unavailableCandidates.includes('lock.hp104_orphan');
const callsBeforeRejected = calls.length;
c._lockAction('lock.hp104_disabled', 'lock');
c._lockAction('lock.hp104_disabled_parent', 'lock');
c._lockAction('lock.hp104_orphan', 'lock');
result.rejectedLocksNeverCallService = calls.length === callsBeforeRejected;
return result;
});
checkAll(out);
await finish(browser, out);