mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Гард «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
251 lines
12 KiB
JavaScript
251 lines
12 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 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');
|