Files
houseplan-card/demo/smoke_space_card.mjs
T
Matysh 2903374b72 fix: the exception guard reads its counter after delivery, not before
Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и
не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а
Playwright доставляет pageerror асинхронно по CDP: если исключение возникло
после последнего обращения смока к странице, счётчик к моменту проверки
нулевой, а browser.close() уносит недоставленное событие. В логе это видно
дословно — EXC печатается после результата и до OK.

finish() теперь делает round-trip по открытым страницам перед чтением
счётчика. Страницы регистрируются там, где создаются: ссылок на них у
finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205
смоков.

Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы,
созданные смоком после launch(), регистрация в launchInternal не покрывает:
smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы
smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в
задаче, которая существует ради устранения слепой зоны, значит закрыть issue,
оставив дефект. Наружу отдана одна функция watchPage(page): подписка и
регистрация неразделимы, иначе появится страница, чьи исключения считаются, а
доставки не ждёт никто.

Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два
файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum.
Они не слепая зона, их страница приходит из launchColdView и уже
зарегистрирована, а свой счётчик они превращают в отдельное утверждение.
Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо
гарда» и закреплён по всему набору, а не по двум названным файлам.

reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан
ждать доставку так же, как finish(). Пять смоков получили await.

Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding
и bindingMode _bindingHasHaPage падал на undefined.split(':') — два
исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/,
создающих диалог, binding пишут; врала фикстура.

Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в
demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint,
и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и
golden-индекс — пробы же не касаются ни одного пикселя. Поведение
доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не
ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся
задача.

Issue: #404
User-Visible: no
2026-09-01 19:08:07 +03:00

251 lines
12 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 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 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'),
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.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');