mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
189 lines
8.3 KiB
JavaScript
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);
|