Files
houseplan-card/demo/smoke_space_card.mjs
Claude 40b73cc599 fix(space-card): nothing in the static schematic is hit-testable (#664)
The stage is pointer-events:none, but that is not an inherited ban: the
shared marker styles opt the 44 px floor (.dev::before) and the painted
capsule (.device-shell-frame) back in for the interactive plan (#564).
The static card imports them whole, so since v1.76.0-beta.2 it showed a
hand cursor over every marker and swallowed clicks that do nothing.

The card now closes every descendant opt-in (.hp-static-stage *, ::before,
::after). smoke_space_card asserts the browser hit test itself
(elementFromPoint over the marker and a 9x9 grid across the stage) and
the cursor, not the stage computed style that stayed green through the
regression. Mutant static-card-descendants-hit-testable.

Screenshots: 11 frames pixel-identical, fingerprint only
(npm run docs:accept -- --identical).

Issue: #664
User-Visible: yes
2026-09-26 13:02:51 +03:00

304 lines
15 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.
// Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button.
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const hass = window.__card.hass;
const alarmDevice = window.__card._devices.find((device) => device.id === 'd_leak');
if (alarmDevice?.primary && hass.states[alarmDevice.primary]) {
hass.states = {
...hass.states,
[alarmDevice.primary]: { ...hass.states[alarmDevice.primary], state: 'on' },
};
}
const spaceId = window.__card._model[0].id;
// Give the real static renderer structural voters that room-only fixtures
// cannot prove: an independent wall, saved draft, column and hosted door.
const response = await hass.callWS({ type: 'houseplan/config/get' });
const configs = [...new Set([response?.config, window.__card._serverCfg])].filter(Boolean);
for (const config of configs) {
const fixture = config.spaces.find((space) => space.id === spaceId);
fixture.cell_cm = 5;
fixture.settings = {
...(fixture.settings || {}), show_borders: true, show_names: true, hide_openings: false,
};
fixture.partitions = [{ id: 'fit-wall', a: [0.20, 0.25], b: [0.80, 0.25], cm: 15 }];
fixture.room_drafts = [{
id: 'fit-draft', points: [[0.18, 0.90], [0.42, 0.90]], segments: [{ cm: 10 }],
}];
fixture.wall_columns = [{ id: 'fit-column', shape: 'circle', center: [0.84, 0.90], cm: 30 }];
fixture.openings = [{
id: 'fit-door', type: 'door', length: 0.14,
host: { kind: 'partition', id: 'fit-wall', t: 0.5 },
}];
}
const host = document.createElement('div');
document.body.appendChild(host);
const mk = (cfg) => { const el = document.createElement('houseplan-space-card'); el.setConfig(cfg); el.hass = hass; host.appendChild(el); return el; };
const card = mk({ type: 'custom:houseplan-space-card', space: spaceId, button_target: '/plan-doma' });
const explicitContent = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'content' });
const unknownFit = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'cover' });
const tight = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house' });
const tightNoTitle = mk({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house', title: '' });
const named = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: 'Named floor' });
const compact = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: '' });
const compactNoButton = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: '', show_button: false });
// #376(а): YAML `title:` with no value is null — same compact contract as ''
const nullTitle = mk({ type: 'custom:houseplan-space-card', space: spaceId, title: null });
const narrowHost = document.createElement('div');
narrowHost.style.width = '320px';
compact.before(narrowHost);
narrowHost.appendChild(compact);
compact.hass = { ...hass, themes: { ...(hass.themes || {}), darkMode: false } };
const wideHost = document.createElement('div');
wideHost.style.width = '900px';
compactNoButton.before(wideHost);
wideHost.appendChild(compactNoButton);
compactNoButton.hass = { ...hass, themes: { ...(hass.themes || {}), darkMode: true } };
const waitForStage = async (el) => {
const t0 = Date.now();
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise(r => setTimeout(r, 80));
}
await el.updateComplete;
};
await Promise.all([
card, explicitContent, unknownFit, tight, tightNoTitle,
named, compact, compactNoButton,
].map(waitForStage));
const frameOf = (el) => {
const vb = el.renderRoot.querySelector('.hp-static-stage svg')?.viewBox?.baseVal;
return vb ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : null;
};
const frame = frameOf(card);
const explicitContentFrame = frameOf(explicitContent);
const unknownFitFrame = frameOf(unknownFit);
const tightFrame = frameOf(tight);
const tightNoTitleFrame = frameOf(tightNoTitle);
const namedFrame = frameOf(named);
const compactFrame = frameOf(compact);
const compactNoButtonFrame = frameOf(compactNoButton);
const nullTitleFrame = frameOf(nullTitle);
// #384: hidden architecture must not vote in fit: house. Twin cards over a
// config clone with a far-away wall column; only show_borders differs.
const hiddenTwinFrames = await (async () => {
// Twin cards share the page config store; patch each twin's reactive
// snapshot directly — a deep clone with a far-away wall column, differing
// only in show_borders.
const twin = (borders) => {
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house' });
el.hass = card.hass;
host.appendChild(el);
return el;
};
const hidden = twin(false);
const shown = twin(true);
await waitForStage(hidden); await waitForStage(shown);
const patchSnap = async (el, borders) => {
// clone ONLY the config: virtualLights carries a Set that JSON kills
const snap = { ...el._snap, config: JSON.parse(JSON.stringify(el._snap.config)) };
const sp = snap.config.spaces.find((x) => x.id === spaceId);
const pts = (sp.rooms || []).flatMap((room) => (room.poly || [])
.map((pt) => Array.isArray(pt) ? pt : [pt.x, pt.y]));
const maxX = Math.max(...pts.map((pt) => pt[0]));
const midY = pts.reduce((acc, pt) => acc + pt[1], 0) / pts.length;
sp.wall_columns = [...(sp.wall_columns || []), {
id: 'far-column-384', shape: 'rect', center: [maxX + 300, midY], cm: 40, angle: 0,
}];
sp.settings = { ...(sp.settings || {}), show_borders: borders };
el._snap = snap;
await el.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
await el.updateComplete;
};
await patchSnap(hidden, false);
await patchSnap(shown, true);
const frames = { hidden: frameOf(hidden), shown: frameOf(shown) };
hidden.remove(); shown.remove();
return frames;
})();
const nullTitleHasTitle = !!nullTitle.renderRoot.querySelector('.hp-static-title');
const compactCardBox = compact.renderRoot.querySelector('ha-card')?.getBoundingClientRect();
const compactStageBox = compact.renderRoot.querySelector('.hp-static-stage')?.getBoundingClientRect();
const wideStageBox = compactNoButton.renderRoot.querySelector('.hp-static-stage')?.getBoundingClientRect();
const compactTopGap = compactCardBox && compactStageBox
? Math.abs(compactStageBox.top - compactCardBox.top) : null;
const stage = card.renderRoot.querySelector('.hp-static-stage');
const pe = stage ? getComputedStyle(stage).pointerEvents : null;
const markers = card.renderRoot.querySelectorAll('.hp-static-stage .devlayer .dev').length;
const litMarker = card.renderRoot.querySelector('.hp-static-stage .dev[data-id="d_light1"]');
const alarmMarker = alarmDevice
? card.renderRoot.querySelector(`.hp-static-stage .dev[data-id="${alarmDevice.id}"]`)
: null;
const alarmSegments = (alarmMarker?.getAttribute('aria-label') || '').split(', ')
.map((value) => value.trim().toLowerCase()).filter(Boolean);
const btn = card.renderRoot.querySelector('.hp-static-btn');
// #664: the stage is pointer-events:none, but that is not an inherited ban —
// a descendant that opts back in (the shared 44 px floor and painted capsule
// of #564) becomes a hit target again, shows a pointer cursor and swallows
// clicks that do nothing. Ask the browser's hit test itself, not the stage's
// computed style: nothing inside the schematic may be under the pointer.
litMarker?.scrollIntoView({ block: 'center', inline: 'center' });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const hitAt = (x, y) => card.renderRoot.elementFromPoint(x, y);
const insideStage = (el) => !!el && !!stage && (el === stage || stage.contains(el));
const markerBox = litMarker?.getBoundingClientRect();
const markerHit = markerBox
? hitAt(markerBox.left + markerBox.width / 2, markerBox.top + markerBox.height / 2) : null;
const stageBox = stage?.getBoundingClientRect();
const gridHits = [];
if (stageBox) {
for (let i = 1; i < 10; i += 1) {
for (let j = 1; j < 10; j += 1) {
const x = stageBox.left + (stageBox.width * i) / 10;
const y = stageBox.top + (stageBox.height * j) / 10;
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) continue;
gridHits.push(hitAt(x, y));
}
}
}
// deep-link: clicking the button pushes #space=<id>
let pushed = null;
const orig = history.pushState;
history.pushState = function (a, b, url) { pushed = url; return orig.apply(this, arguments); };
btn?.click();
history.pushState = orig;
// error card for an unknown space
const bad = mk({ type: 'custom:houseplan-space-card', space: '__nope__' });
await bad.updateComplete;
const errCard = bad.renderRoot.querySelector('.hp-static-error');
return {
stagePointerEvents: pe,
markerProbe: markerHit ? {
insideStage: insideStage(markerHit),
element: `${markerHit.tagName.toLowerCase()}.${[...markerHit.classList].join('.')}`,
cursor: getComputedStyle(markerHit).cursor,
} : null,
stageGridProbes: gridHits.length,
stageGridHitsInside: gridHits.filter(insideStage).length,
stageGridPointerCursors: gridHits.filter((el) => el && getComputedStyle(el).cursor === 'pointer').length,
markers,
litMarkerOn: !!litMarker?.classList.contains('on'),
sharedFacePresent: !!litMarker?.querySelector('ha-icon'),
alarmLabelHasNoRepeats: alarmSegments.length >= 2
&& new Set(alarmSegments).size === alarmSegments.length
&& alarmSegments.filter((part) => part === 'alarm').length === 1,
omittedTitle: card.renderRoot.querySelector('.hp-static-title')?.textContent?.trim() || null,
namedTitle: named.renderRoot.querySelector('.hp-static-title')?.textContent?.trim() || null,
compactHasTitle: !!compact.renderRoot.querySelector('.hp-static-title'),
compactTopGap,
compactWidths: [compactStageBox?.width || 0, wideStageBox?.width || 0],
compactOverflow: [compact, compactNoButton].some((el) => {
const body = el.renderRoot.querySelector('.hp-static-body');
return body ? body.scrollWidth > body.clientWidth + 1 : true;
}),
compactThemes: [compact.hass.themes.darkMode, compactNoButton.hass.themes.darkMode],
frame,
explicitContentFrame,
unknownFitFrame,
tightFrame,
tightNoTitleFrame,
tightPointerEvents: getComputedStyle(
tight.renderRoot.querySelector('.hp-static-stage'),
).pointerEvents,
tightStructuralEdges: (() => {
const vb = tightFrame;
if (!vb) return null;
const rooms = [...tight.renderRoot.querySelectorAll('[data-hp="room"]')];
const points = rooms.flatMap((room) => {
if (room.tagName.toLowerCase() === 'polygon') {
return Array.from(room.points).map((point) => [point.x, point.y]);
}
const x = room.x.baseVal.value, y = room.y.baseVal.value;
const w = room.width.baseVal.value, h = room.height.baseVal.value;
return [[x, y], [x + w, y + h]];
});
return points.every(([x, y]) =>
x >= vb.x && x <= vb.x + vb.w && y >= vb.y && y <= vb.y + vb.h);
})(),
tightPaintedEnvelope: (() => {
const svg = tight.renderRoot.querySelector('.hp-static-stage svg');
if (!svg) return null;
const viewport = svg.getBoundingClientRect();
const nodes = [...svg.querySelectorAll('.wallbody, .zero-wall, .static-opening')];
return {
wall: !!svg.querySelector('.wallbody'),
opening: !!svg.querySelector('[data-id="fit-door"]'),
contained: nodes.length > 0 && nodes.every((node) => {
const box = node.getBoundingClientRect();
return box.left >= viewport.left - 0.51 && box.right <= viewport.right + 0.51
&& box.top >= viewport.top - 0.51 && box.bottom <= viewport.bottom + 0.51;
}),
};
})(),
nullTitleFrame,
nullTitleHasTitle,
hiddenTwinFrames,
namedFrame,
compactFrame,
compactNoButtonFrame,
hasButton: !!btn,
deepLink: pushed,
errorShown: !!errCard,
errorText: errCard?.textContent?.trim() || null,
};
});
await browser.close();
const ok =
res.stagePointerEvents === 'none' &&
// #664 AC1/AC2: the browser's hit test finds nothing inside the schematic —
// not the marker, not the paper — and no probe shows a pointer cursor.
res.markerProbe && !res.markerProbe.insideStage && res.markerProbe.cursor !== 'pointer' &&
res.stageGridProbes >= 40 && res.stageGridHitsInside === 0 && res.stageGridPointerCursors === 0 &&
res.markers > 0 &&
res.litMarkerOn &&
res.sharedFacePresent &&
res.alarmLabelHasNoRepeats &&
!!res.omittedTitle &&
res.namedTitle === 'Named floor' &&
!res.compactHasTitle &&
res.compactTopGap !== null && res.compactTopGap < 0.51 &&
res.compactWidths[0] > 300 && res.compactWidths[0] <= 320 &&
res.compactWidths[1] > 880 && res.compactWidths[1] <= 900 &&
!res.compactOverflow &&
JSON.stringify(res.compactThemes) === JSON.stringify([false, true]) &&
res.frame && res.namedFrame && res.compactFrame && res.compactNoButtonFrame &&
res.explicitContentFrame && res.unknownFitFrame && res.tightFrame && res.tightNoTitleFrame &&
JSON.stringify(res.frame) === JSON.stringify(res.explicitContentFrame) &&
JSON.stringify(res.frame) === JSON.stringify(res.unknownFitFrame) &&
JSON.stringify(res.tightFrame) === JSON.stringify(res.tightNoTitleFrame) &&
res.tightFrame.w < res.frame.w && res.tightFrame.h < res.frame.h &&
res.tightFrame.x > res.frame.x && res.tightFrame.y > res.frame.y &&
res.tightPointerEvents === 'none' && res.tightStructuralEdges &&
res.tightPaintedEnvelope?.wall && res.tightPaintedEnvelope?.opening &&
res.tightPaintedEnvelope?.contained &&
JSON.stringify(res.frame) === JSON.stringify(res.namedFrame) &&
res.compactFrame.x === res.frame.x &&
res.compactFrame.w === res.frame.w &&
res.compactFrame.y > res.frame.y &&
Math.abs((res.compactFrame.y + res.compactFrame.h) - (res.frame.y + res.frame.h)) < 1e-6 &&
JSON.stringify(res.compactFrame) === JSON.stringify(res.compactNoButtonFrame) &&
!res.nullTitleHasTitle &&
res.hiddenTwinFrames && res.hiddenTwinFrames.hidden && res.hiddenTwinFrames.shown &&
// #384 AC1: скрытая архитектура не голосует — кадр без колонны строго уже
(res.hiddenTwinFrames.hidden.x + res.hiddenTwinFrames.hidden.w)
< (res.hiddenTwinFrames.shown.x + res.hiddenTwinFrames.shown.w) - 100 &&
JSON.stringify(res.nullTitleFrame) === JSON.stringify(res.compactFrame) &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (await reportPageErrors()) process.exit(1);
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: live shared marker face, pointer-events:none, nothing hit-testable in the schematic (#664), deep-link button, error card');