Files
houseplan-card/demo/smoke_cold_view_toggle.mjs
T
Codex 49174e81f7 fix: a cold View resolves taps without the lazy editor runtime (#357)
Field report from the dacha: the wall switch "Гостиная основной свет",
whose controls name three virtual light sources, periodically ignored taps
— no toggle, no glow — until its settings dialog was opened once with no
changes. "Periodically" was every fresh tab: the #337 lazy split left
_toggleIntent (and the confirm-line helpers) on the card as stubs
delegating into the editor runtime, so a plain View tap on a cold tab
threw `Houseplan editor runtime is not loaded` synchronously inside the
click handler. Opening any editor surface loaded the runtime and "healed"
the tab for its lifetime.

The View card now owns toggle resolution: _toggleIntent calls
resolveToggleIntent directly (device-toggle.ts was already in the initial
graph; the card owns _planHass/_fullRegistryHass/_virtualLights), and
_toggleStateText/_toggleConfirmationStateText/_toggleConfirmationLines
moved with it. The editor runtime delegates back to the host — one source
of truth, editor consumers (dialog preview, hint lines) unchanged.

Every product smoke preloads the runtime, so none of them could see this
class of regression. The new smoke_cold_view_toggle mirrors the field
config on a genuinely cold tab: a real switch drives three passive
virtual lamps with one tap, a controlled lamp drives its switch back,
tap_confirm renders its state lines and confirms, and the editor chunk is
never requested. A registry mutant restores the old delegation and is
killed by that smoke.

Issue: #357
User-Visible: yes
2026-08-28 21:02:19 +03:00

188 lines
7.6 KiB
JavaScript

// #357: a plain View tap must work on a COLD tab that never loaded the lazy
// editor runtime (#337). The field failure (dacha, "Гостиная основной свет"):
// a real wall switch whose controls name three passive virtual lamps did
// nothing on tap — _toggleIntent was a stub delegating into the not-yet-loaded
// editor runtime and threw synchronously inside the click handler; opening any
// editor surface "healed" the tab. Every product smoke preloads the runtime
// (launch()), so only a cold-view scenario can see this class of regression.
import { launchColdView, checkAll, finish } from './serve.mjs';
const { page, browser } = await launchColdView();
const requested = [];
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
let pageErrors = 0;
page.on('pageerror', (error) => { pageErrors++; console.log('EXC', error.message); });
const out = await page.evaluate(async () => {
const card = window.__card;
const space = card._model[0];
const room = space.rooms.find((candidate) => candidate.id && candidate.area);
const virtualIds = ['v_cold_1', 'v_cold_2', 'v_cold_3'];
const controllerId = 'd_kettle'; // real switch device — the wall-switch analog
const confirmId = 'v_cold_confirm';
const serverConfig = structuredClone(card._serverCfg);
// The exact field shape: passive virtual lamps owned by a real switch.
serverConfig.markers = [...(serverConfig.markers || []),
...virtualIds.map((id, index) => ({
id,
name: `Cold main light ${index + 1}`,
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
space: space.id,
area: room.area,
room_id: room.id,
})),
{
id: confirmId,
name: 'Cold confirmed lamp',
binding: 'virtual',
is_light: true,
tap_action: 'toggle',
tap_confirm: true,
space: space.id,
area: room.area,
room_id: room.id,
}];
// Demo devices come straight from the registry without explicit markers —
// persist one for the switch exactly like the marker dialog would.
serverConfig.markers.push({
id: controllerId,
binding: `device:${controllerId}`,
name: 'Cold wall switch',
tap_action: 'toggle',
controls: virtualIds.map((id) => `marker:${id}`),
space: space.id,
area: room.area,
room_id: room.id,
});
const serverLayout = { ...card._layout };
[...virtualIds, confirmId].forEach((id, index) => {
serverLayout[id] = { s: space.id, x: 0.2 + index * 0.12, y: 0.72 };
});
const virtual = { rev: 0, config_rev: card._cfgRev, off: [] };
const subscriptions = new Map();
const serviceCalls = [];
let toggleCalls = 0;
const emit = (event, data) => {
for (const listener of subscriptions.get(event) || []) listener({ data });
};
const baseHass = card.hass;
const baseCallWS = baseHass.callWS.bind(baseHass);
const makeHass = () => ({
...baseHass,
states: { ...baseHass.states },
connection: {
...baseHass.connection,
subscribeEvents: async (callback, event) => {
const listeners = subscriptions.get(event) || new Set();
listeners.add(callback);
subscriptions.set(event, listeners);
return () => listeners.delete(callback);
},
},
callService: async (domain, service, data) => {
serviceCalls.push({ domain, service, data });
// The stub HA honestly flips the switch and pushes a fresh state frame.
const target = [].concat(data?.entity_id || [])[0];
const current = hass.states[target]?.state;
const next = service === 'toggle' ? (current === 'on' ? 'off' : 'on')
: service === 'turn_on' ? 'on' : 'off';
const fresh = makeHass();
fresh.states[target] = { ...hass.states[target], state: next };
hass = fresh;
card.hass = fresh;
await card.updateComplete;
},
callWS: async (message) => {
if (message.type === 'houseplan/config/get') return {
config: serverConfig,
rev: virtual.config_rev,
can_write: true,
virtual_lights: structuredClone(virtual),
};
if (message.type === 'houseplan/layout/get') return {
layout: serverLayout,
rev: card._layoutRev,
};
if (message.type === 'houseplan/virtual_light/toggle') {
toggleCalls++;
const off = new Set(virtual.off);
if (off.has(message.marker_id)) off.delete(message.marker_id);
else off.add(message.marker_id);
virtual.off = [...off].sort();
virtual.rev++;
const reply = {
marker_id: message.marker_id,
on: !off.has(message.marker_id),
rev: virtual.rev,
};
queueMicrotask(() => emit('houseplan_virtual_light_updated', reply));
return reply;
}
return baseCallWS(message);
},
});
let hass = makeHass();
hass.states[`switch.kettle`] = { ...hass.states['switch.kettle'], state: 'on' };
card._unsubVirtual?.();
card._unsubVirtual = null;
card.hass = hass;
await card._reloadConfigOnly(true);
card._layout = serverLayout;
card._regSignature = '';
card._maybeRebuildDevices();
card._ensureLiveSyncSubscriptions();
await card.updateComplete;
const waitFor = async (predicate, timeout = 6000) => {
const started = Date.now();
while (!predicate() && Date.now() - started < timeout) {
await new Promise((resolve) => setTimeout(resolve, 40));
}
return !!predicate();
};
await waitFor(() => card._devices.some((device) => device.id === virtualIds[2]));
await card.updateComplete;
const node = (id) => card.renderRoot.querySelector(`.dev[data-id="${id}"]`);
const isOn = (id) => card._stateClass(card._devices.find((d) => d.id === id)).includes('on');
const allVirtualOn = () => virtualIds.every(isOn);
const out = { runtimeColdBefore: !card._editorRuntime };
// Passive lamps follow their controller: with the switch on they glow.
out.initialAllOn = await waitFor(allVirtualOn);
// One tap on the wall switch drives its own entity; all three follow off.
node(controllerId).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
out.controllerTapCallsSwitch = await waitFor(() => serviceCalls.length === 1
&& [].concat(serviceCalls[0].data?.entity_id || [])[0] === 'switch.kettle');
out.controllerTapTurnsAllOff = await waitFor(() => !virtualIds.some(isOn));
node(controllerId).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
out.controllerTapTurnsAllOn = await waitFor(() => serviceCalls.length === 2 && allVirtualOn());
// A tap on a controlled lamp drives the same wall switch (wired semantics).
node(virtualIds[0]).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
out.lampTapDrivesSwitch = await waitFor(() => serviceCalls.length === 3
&& [].concat(serviceCalls[2].data?.entity_id || [])[0] === 'switch.kettle');
out.lampTapTurnsAllOff = await waitFor(() => !virtualIds.some(isOn));
// An independent manual virtual lamp with tap_confirm: the dialog opens with
// rendered state lines and the confirmation toggles it over the WS API.
node(confirmId).dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
await card.updateComplete;
out.confirmDialogShown = !!card._tapConfirm
&& Array.isArray(card._tapConfirm.lines) && card._tapConfirm.lines.length > 0;
card._tapConfirm?.exec?.();
out.confirmExecTogglesVirtual = await waitFor(() => toggleCalls === 1);
card._tapConfirm = null;
out.runtimeColdAfter = !card._editorRuntime;
return out;
});
out.noEditorRuntimeRequest = requested.every((path) => !/houseplan-editor-runtime-/.test(path));
out.noPageErrors = pageErrors === 0;
checkAll(out);
await finish(browser, out);