mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
304 lines
15 KiB
JavaScript
304 lines
15 KiB
JavaScript
// 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');
|