Files
houseplan-card/demo/smoke_wireless_controller_parity.mjs
2026-08-28 13:07:58 +00:00

295 lines
11 KiB
JavaScript

// #274: a wireless controller whose event is `unknown` still has live
// battery/LQI/update siblings. A deleted standalone marker for its display
// source used to constrain the saved plan roster but disappear from
// deviceFromMarkerDraft(), so plan and preview classified the same marker
// differently and controllerAvailability() was skipped on the saved face.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const result = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const room = c._spaceModel().rooms.find((item) => item.id && item.area);
const deviceId = 'issue-274-wireless';
const markerId = 'issue-274-marker';
const tombstoneId = 'issue-274-target-tombstone';
const target = 'light.floor_lamp';
const own = [
'event.issue_274_action',
'sensor.issue_274_battery',
'sensor.issue_274_linkquality',
'update.issue_274',
];
const marker = {
id: markerId,
binding: `device:${deviceId}`,
name: 'Issue 274 wireless switch',
icon: 'mdi:light-switch',
tap_action: 'toggle',
controls: [target],
space: c._space,
area: room.area,
};
const tombstone = {
id: tombstoneId,
binding: `entity:${target}`,
removed: true,
hidden: true,
};
c.hass = {
...c.hass,
devices: {
...c.hass.devices,
[deviceId]: {
id: deviceId,
name: 'Issue 274 wireless switch',
model: 'Wireless switch',
area_id: room.area,
identifiers: [['demo', deviceId]],
entry_type: null,
via_device_id: null,
},
},
entities: {
...c.hass.entities,
[own[0]]: { entity_id: own[0], device_id: deviceId, platform: 'demo' },
[own[1]]: {
entity_id: own[1], device_id: deviceId, platform: 'demo',
entity_category: 'diagnostic',
},
[own[2]]: {
entity_id: own[2], device_id: deviceId, platform: 'demo',
entity_category: 'diagnostic',
},
[own[3]]: {
entity_id: own[3], device_id: deviceId, platform: 'demo',
entity_category: 'config',
},
},
states: {
...c.hass.states,
[own[0]]: { entity_id: own[0], state: 'unknown', attributes: {} },
[own[1]]: {
entity_id: own[1], state: '100',
attributes: { device_class: 'battery', unit_of_measurement: '%' },
},
[own[2]]: {
entity_id: own[2], state: '164',
attributes: { unit_of_measurement: 'lqi' },
},
[own[3]]: { entity_id: own[3], state: 'off', attributes: {} },
[target]: { ...c.hass.states[target], state: 'off' },
},
};
c._serverCfg = {
...c._serverCfg,
markers: [
...(c._serverCfg.markers || []).filter((item) =>
![markerId, tombstoneId].includes(item.id)),
marker,
tombstone,
],
};
c._layout = {
...c._layout,
[markerId]: { s: c._space, x: 0.5, y: 0.5 },
};
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
const controller = () => c._devices.find((item) => item.id === markerId);
result.savedControllerBuilt = !!controller();
result.savedRosterHasAllSiblings = JSON.stringify(controller()?.entities) === JSON.stringify(own);
result.deletedStandaloneTargetIsRuntimeFiltered = controller()?.controls?.length === 0;
c._setMode('view');
await c.updateComplete;
const savedPresentation = c._devicePresentation(controller(), true);
const planElement = sr().querySelector(`.dev[data-id="${markerId}"]`);
result.planAvailableNeutral = savedPresentation.visual.availability === 'available'
&& savedPresentation.visual.status === 'neutral'
&& !savedPresentation.classes.includes('unavail');
result.planKeepsLiveLqi = savedPresentation.lqiText === '164'
&& planElement?.textContent?.includes('164');
result.planDomAgrees = planElement?.getAttribute('data-state') === 'neutral'
&& !planElement?.classList.contains('unavail');
c._setMode('devices');
await c.updateComplete;
c._openMarkerDialog(controller());
await c.updateComplete;
const previewDevice = c._markerPreviewDevice(c._markerDialog);
const preview = sr().querySelector('hp-device-preview');
await preview?.updateComplete;
result.previewHonoursFullMarkerRoster = previewDevice?.controls?.length === 0;
result.previewMatchesPlan = preview?.presentation?.sourceKind === savedPresentation.sourceKind
&& JSON.stringify(preview?.presentation?.visual) === JSON.stringify(savedPresentation.visual)
&& JSON.stringify(preview?.presentation?.classes) === JSON.stringify(savedPresentation.classes)
&& preview?.presentation?.lqiText === savedPresentation.lqiText;
result.previewTextIsNotUnavailable = !preview?.shadowRoot?.textContent?.includes('Unavailable');
// Remove only the standalone marker tombstone: the configured group is now
// active again, and both surfaces must follow its working state together.
c._serverCfg = {
...c._serverCfg,
markers: c._serverCfg.markers.filter((item) => item.id !== tombstoneId),
};
c.hass = {
...c.hass,
states: { ...c.hass.states, [target]: { ...c.hass.states[target], state: 'on' } },
};
c._regSignature = '';
c._maybeRebuildDevices();
c.requestUpdate();
await c.updateComplete;
const activePresentation = c._devicePresentation(controller(), true, true);
const activePreviewDevice = c._markerPreviewDevice(c._markerDialog);
const activePreview = sr().querySelector('hp-device-preview');
await activePreview?.updateComplete;
result.activeTargetRestoredOnBothRosters = JSON.stringify(controller()?.controls) === JSON.stringify([target])
&& JSON.stringify(activePreviewDevice?.controls) === JSON.stringify([target]);
result.activeTargetWorksOnBothSurfaces = activePresentation.visual.status === 'working'
&& activePreview?.presentation?.visual?.status === 'working';
// #318: an active physical HA device may legitimately expose no entity
// registry rows. Empty telemetry is not evidence that this controller is
// offline; plan and preview must follow only the external target status.
const entitylessDeviceId = 'issue-318-entityless-device';
const entitylessMarkerId = 'issue-318-entityless-marker';
const entitylessTarget = 'light.issue_318_target';
const entitylessTargetDeviceId = 'issue-318-lights';
const entitylessMarker = {
id: entitylessMarkerId,
binding: `device:${entitylessDeviceId}`,
name: 'Issue 318 entityless switch',
icon: 'mdi:light-switch',
tap_action: 'toggle',
controls: [entitylessTarget],
space: c._space,
area: room.area,
};
c.hass = {
...c.hass,
devices: {
...c.hass.devices,
[entitylessDeviceId]: {
id: entitylessDeviceId,
name: 'Issue 318 entityless switch',
model: 'Entityless wireless switch',
area_id: room.area,
identifiers: [['demo', entitylessDeviceId]],
entry_type: null,
via_device_id: null,
},
[entitylessTargetDeviceId]: {
id: entitylessTargetDeviceId,
name: 'Issue 318 lights',
model: 'Demo light group',
area_id: room.area,
identifiers: [['demo', entitylessTargetDeviceId]],
entry_type: null,
via_device_id: null,
},
},
entities: {
...c.hass.entities,
[entitylessTarget]: {
entity_id: entitylessTarget,
device_id: entitylessTargetDeviceId,
platform: 'demo',
},
},
states: {
...c.hass.states,
[entitylessTarget]: { entity_id: entitylessTarget, state: 'on', attributes: {} },
},
};
c._serverCfg = {
...c._serverCfg,
markers: [
...c._serverCfg.markers.filter((item) => item.id !== entitylessMarkerId),
entitylessMarker,
],
};
c._layout = {
...c._layout,
[entitylessMarkerId]: { s: c._space, x: 0.58, y: 0.5 },
};
c._regSignature = '';
c._maybeRebuildDevices();
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
const entitylessController = () => c._devices.find((item) => item.id === entitylessMarkerId);
const onPresentation = c._devicePresentation(entitylessController(), true);
const onElement = sr().querySelector(`.dev[data-id="${entitylessMarkerId}"]`);
result.entitylessDeviceHasEmptyRoster = entitylessController()?.entities?.length === 0;
result.entitylessOnIsAvailableWorking = onPresentation.visual.availability === 'available'
&& onPresentation.visual.status === 'working'
&& onElement?.getAttribute('data-state') === 'working'
&& !onElement?.classList.contains('unavail');
c._setMode('devices');
await c.updateComplete;
c._openMarkerDialog(entitylessController());
await c.updateComplete;
const entitylessPreview = sr().querySelector('hp-device-preview');
await entitylessPreview?.updateComplete;
result.entitylessPreviewMatchesOnPlan = entitylessPreview?.presentation?.visual?.availability
=== onPresentation.visual.availability
&& entitylessPreview?.presentation?.visual?.status === onPresentation.visual.status
&& entitylessPreview?.presentation?.sourceKind === onPresentation.sourceKind
&& JSON.stringify(entitylessPreview?.presentation?.classes)
=== JSON.stringify(onPresentation.classes);
c.hass = {
...c.hass,
states: {
...c.hass.states,
[entitylessTarget]: { ...c.hass.states[entitylessTarget], state: 'off' },
},
};
c.requestUpdate();
await c.updateComplete;
await entitylessPreview?.updateComplete;
const offPresentation = c._devicePresentation(entitylessController(), true, true);
result.entitylessOffIsAvailableNeutralEverywhere = offPresentation.visual.availability
=== 'available'
&& offPresentation.visual.status === 'neutral'
&& entitylessPreview?.presentation?.visual?.availability === 'available'
&& entitylessPreview?.presentation?.visual?.status === 'neutral'
&& entitylessPreview?.presentation?.sourceKind === offPresentation.sourceKind
&& JSON.stringify(entitylessPreview?.presentation?.classes)
=== JSON.stringify(offPresentation.classes);
c.hass = {
...c.hass,
states: {
...c.hass.states,
[entitylessTarget]: { ...c.hass.states[entitylessTarget], state: 'unavailable' },
},
};
c.requestUpdate();
await c.updateComplete;
await entitylessPreview?.updateComplete;
const unavailableTargetPresentation = c._devicePresentation(entitylessController(), true, true);
result.entitylessUnavailableTargetIsAvailableNeutralEverywhere
= unavailableTargetPresentation.visual.availability === 'available'
&& unavailableTargetPresentation.visual.status === 'neutral'
&& entitylessPreview?.presentation?.visual?.availability === 'available'
&& entitylessPreview?.presentation?.visual?.status === 'neutral'
&& entitylessPreview?.presentation?.sourceKind === unavailableTargetPresentation.sourceKind
&& JSON.stringify(entitylessPreview?.presentation?.classes)
=== JSON.stringify(unavailableTargetPresentation.classes);
return result;
});
checkAll(out);
await finish(browser, out);