Files
houseplan-card/demo/smoke_cold_view_vacuum.mjs
Codex bb3c1fd12f fix: a cold View survives a telemetry-bearing vacuum (#358)
Systematic audit after #357 ("can there be more bugs with this root
cause?"): _vacMapId was the one remaining hard stub reachable from the
eager View path. It runs inside willUpdate for every vacuum whose
integration reports live telemetry (Tasshack, XCME, Valetudo), so on a
cold tab the #337 stub threw there and the exception took the whole Lit
update cycle with it — the card froze on its very first frame. The demo
mower has no position attributes, telemetry resolved to null, and every
existing smoke (warm and cold) sailed past the branch.

The card now owns the implementation (both dependencies — _vacEntity and
vacMapIdWithFallback — were already eager); the editor runtime delegates
back to the host. The HP-1541-01 invariant (selected_map: 0 is a real map
id, nullish not truthy) moves verbatim and is pinned by the new smoke.

Hardened alongside (audit Lows): _decorShapeDown gets the same
cold-tab guard its twin _decorShapeDbl received in #337 — decor shapes
render in View and CSS pointer-events alone must not be what prevents a
throw; the _vacCalConfirm dialog renders behind the same _editorRuntime
gate as every other editor dialog instead of relying on the implicit
"only the runtime ever sets it".

smoke_cold_view_vacuum: cold tab, vacuum with vacuum_position and
selected_map: 0 — the card commits three successive telemetry frames
(willUpdate alive, not merely the first paint), map id resolves to '0',
no editor chunk requested, a decor pointerdown is a quiet no-op. A
registry mutant restores the delegation and is killed by that smoke.

Issue: #358
User-Visible: yes
2026-08-28 21:49:50 +03:00

112 lines
4.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #358: a vacuum with REAL telemetry (position/map — Tasshack, XCME…) must not
// kill a cold View. The #337 stub _vacMapId threw inside willUpdate on a tab
// that never loaded the editor runtime, and the exception took the whole Lit
// update cycle with it: the card froze on its first frame. The demo fixture's
// mower has no position attributes, so telemetry was null and every existing
// smoke (warm AND cold) sailed past this branch.
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 vacState = (x, y) => ({
entity_id: 'vacuum.real',
state: 'cleaning',
attributes: {
friendly_name: 'Робот',
vacuum_position: { x, y, a: 90 },
// no map_name/current_map in telemetry — the entity's selected_map: 0
// must win as the map id (HP-1541-01: zero is a real map, not falsy)
selected_map: 0,
},
});
const makeHass = (x, y) => ({
...card.hass,
states: { ...card.hass.states, 'vacuum.real': vacState(x, y) },
entities: {
...card.hass.entities,
'vacuum.real': {
entity_id: 'vacuum.real', device_id: 'd_realvac',
platform: 'tasshack', disabled_by: null,
},
},
devices: {
...card.hass.devices,
d_realvac: { id: 'd_realvac', name: 'Робот', area_id: room.area },
},
});
const cfg = structuredClone(card._serverCfg);
cfg.markers = [...(cfg.markers || []), {
id: 'd_realvac', binding: 'device:d_realvac', name: 'Робот',
space: space.id, area: room.area, room_id: room.id,
}];
// The decor guard (#358 К2): one shape rendered in View must be a quiet
// no-op on pointerdown, not an exception behind CSS pointer-events.
const sp = cfg.spaces.find((s) => s.id === space.id) || cfg.spaces[0];
sp.decor = [...(sp.decor || []), { id: 'dc_cold', kind: 'line', a: [200, 200], b: [400, 300] }];
const baseWS = card.hass.callWS.bind(card.hass);
const hass = makeHass(120, 340);
hass.callWS = async (message) => message.type === 'houseplan/config/get'
? {
config: cfg, rev: card._cfgRev, can_write: true,
virtual_lights: { rev: 0, config_rev: card._cfgRev, off: [] },
}
: baseWS(message);
card.hass = hass;
await card._reloadConfigOnly(true);
card._regSignature = '';
card._maybeRebuildDevices();
card.requestUpdate();
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();
};
const out = { runtimeColdBefore: !card._editorRuntime };
out.vacuumDeviceBuilt = await waitFor(() =>
card._devices.some((device) => device.id === 'd_realvac'));
out.firstFrameAlive = await waitFor(() =>
!!card.renderRoot.querySelector('.dev[data-id="d_realvac"]'));
// The proof that willUpdate SURVIVES telemetry: push fresh robot positions
// and require the card to keep committing frames, not just the first one.
const snapshotOf = () => card._renderDeviceSnapshot?.facts.get('vacuum:d_realvac');
const before = snapshotOf();
card.hass = { ...makeHass(500, 700), callWS: hass.callWS };
await card.updateComplete;
out.secondFrameCommitted = await waitFor(() => {
const fact = snapshotOf();
return !!fact && fact !== before && fact.telemetry?.pos?.x === 500;
});
// HP-1541-01 preserved through the move: selected_map 0 resolves to '0'.
out.mapIdResolved = snapshotOf()?.mapId === '0';
card.hass = { ...makeHass(800, 200), callWS: hass.callWS };
await card.updateComplete;
out.thirdFrameCommitted = await waitFor(() =>
snapshotOf()?.telemetry?.pos?.x === 800);
// К2: decor shape pointerdown on a cold tab is a quiet no-op.
const shape = card.renderRoot.querySelector('.dshape');
out.decorShapeRendered = !!shape;
shape?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }));
await card.updateComplete;
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);