Files
houseplan-card/demo/smoke_space_card.mjs
T
2026-09-14 07:06:16 +00:00

267 lines
13 KiB
JavaScript
Raw 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');
// 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,
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' &&
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, deep-link button, error card');