// #648: Home Assistant Sections owns the card height. The production bundle // must fill that slot at every row count instead of leaking its viewport-sized // stage through the dashboard, including while editor chrome opens/closes. import { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 1000, height: 940 }, 1); const out = await page.evaluate(async () => { const card = window.__card; const host = document.getElementById('host'); const root = card.shadowRoot || card.renderRoot; const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const waitFor = async (probe, label, timeout = 6000) => { const end = performance.now() + timeout; while (performance.now() < end) { const value = probe(); if (value) return value; await sleep(20); } throw new Error(`Sections smoke timed out: ${label}`); }; const rowHeight = (rows) => rows * 64 - 8; const settle = async () => { await card.updateComplete; await frame(); await frame(); await frame(); }; const rect = (node) => node.getBoundingClientRect(); const geometry = () => { const haCard = root.querySelector('ha-card'); const header = root.querySelector('.hdr'); const stage = root.querySelector('.stage'); return { host: rect(host), card: rect(card), haCard: rect(haCard), header: rect(header), stage: rect(stage), cardScroll: card.scrollHeight - card.clientHeight, haCardScroll: haCard.scrollHeight - haCard.clientHeight, stageStyle: stage.style.height, }; }; const fillsSlot = (sample, expected) => Math.abs(sample.host.height - expected) <= 1 && Math.abs(sample.card.height - expected) <= 1 && Math.abs(sample.haCard.height - expected) <= 1 && Math.abs(sample.header.height + sample.stage.height - expected) <= 1 && sample.stage.height > 0 && sample.cardScroll <= 1 && sample.haCardScroll <= 1; const setRows = async (rows) => { const expected = rowHeight(rows); host.style.height = `${expected}px`; await waitFor(() => Math.abs(card.getBoundingClientRect().height - expected) <= 1, `${rows} rows card height`); await settle(); return geometry(); }; const center = () => { const view = card._view; return view ? [view.x + view.w / 2, view.y + view.h / 2] : null; }; const near = (a, b, tolerance = 0.02) => !!a && !!b && Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance; host.style.cssText = `box-sizing:border-box;width:780px;height:${rowHeight(10)}px;` + 'margin:0;padding:0;overflow:visible;'; document.body.style.margin = '0'; // HA merges explicit dashboard grid_options outside the element. A frozen // input proves the card neither writes those options nor tries to persist a // competing value of its own. const config = Object.freeze({ type: 'custom:houseplan-card', title: 'Sections fixture', language: 'ru', grid_options: Object.freeze({ rows: 14, columns: 'full' }), }); const configBefore = JSON.stringify(config); card.setConfig(config); card.layout = 'grid'; await settle(); const defaults = card.getGridOptions(); const defaultGeometry = await setRows(10); // Absence of HA's grid signal retains the ordinary viewport contract. This // is checked on the real rendered inline style, then the fixture returns to // Sections before any behavioural assertions. card.layout = null; await settle(); const ordinary = geometry(); card.layout = 'grid'; await setRows(10); const minimumGeometry = []; for (const language of ['ru', 'de']) { card.setConfig(Object.freeze({ ...config, language })); await settle(); host.style.width = '390px'; const sample = await setRows(6); const header = root.querySelector('.hdr'); minimumGeometry.push({ language, sample, headerContained: header.scrollHeight - header.clientHeight <= 1, menuReachable: !!root.querySelector('[data-hp="header-menu"]') || !!root.querySelector('[data-hp="mode-tab"]'), }); } host.style.width = '780px'; await setRows(10); root.querySelector('[data-hp="zoom-in"]').click(); await waitFor(() => card._zoom > 1 && !card._cameraTransition?.active, 'zoom before resize'); await settle(); const preservedZoom = card._zoom; const preservedCenter = center(); const resizeHeights = []; let stableView = true; const stableSpace = card._space; const stableMode = card._mode; let moreInfo = 0; card.addEventListener('hass-more-info', () => { moreInfo += 1; }); for (const rows of [6, 10, 14, 6]) { const sample = await setRows(rows); resizeHeights.push(Math.round(sample.stage.height)); stableView &&= Math.abs(card._zoom - preservedZoom) < 1e-9 && near(center(), preservedCenter) && Math.abs(card._view.w / card._view.h - sample.stage.width / sample.stage.height) < 0.001; } // Every editor must borrow its chrome from inside the slot. Exercise both // the default and minimum, and wait for the real animated transition rather // than forcing the private settled state. let editorsContained = true; let editorsSettle = true; let transitionsUseSlotHeight = true; for (const rows of [10, 6]) { await setRows(rows); for (const mode of ['plan', 'devices', 'decor']) { const entering = window.__hpTest.setMode(mode); const enteringState = await waitFor(() => { const state = card._modeTransition.state; return state?.targetMode === mode ? state : null; }, `${mode} transition frame at ${rows} rows`); transitionsUseSlotHeight &&= enteringState.to.stageHeight <= card.clientHeight + 1; await entering; await waitFor(() => card._mode === mode && !card._modeTransitionBusy, `${mode} enter at ${rows} rows`); await settle(); const entered = geometry(); editorsContained &&= fillsSlot(entered, rowHeight(rows)); editorsSettle &&= !root.querySelector('.stage').hasAttribute('inert') && !root.querySelector('.editorchrome')?.classList.contains('transitioning'); const leaving = window.__hpTest.setMode('view'); const leavingState = await waitFor(() => { const state = card._modeTransition.state; return state?.targetMode === 'view' ? state : null; }, `${mode} exit transition frame at ${rows} rows`); transitionsUseSlotHeight &&= leavingState.to.stageHeight <= card.clientHeight + 1; await leaving; await waitFor(() => card._mode === 'view' && !card._modeTransitionBusy, `${mode} exit at ${rows} rows`); await settle(); editorsContained &&= fillsSlot(geometry(), rowHeight(rows)); } } // A short resize storm is the failure mode most likely to expose a // ResizeObserver feedback loop. Require the last size to win in a bounded // interval and prove the geometry stops changing after settling. const stormStarted = performance.now(); for (let index = 0; index < 30; index++) { host.style.height = `${rowHeight(index % 2 ? 6 : 14)}px`; await frame(); } host.style.height = `${rowHeight(10)}px`; await waitFor(() => Math.abs(card.clientHeight - rowHeight(10)) <= 1 && Math.abs(root.querySelector('.hdr').getBoundingClientRect().height + root.querySelector('.stage').getBoundingClientRect().height - rowHeight(10)) <= 1, 'resize storm final geometry'); await settle(); const stormElapsed = performance.now() - stormStarted; const settledStage = geometry().stage; await frame(); await frame(); await frame(); await frame(); const finalGeometry = geometry(); const finalStage = root.querySelector('.stage'); const stageRect = finalStage.getBoundingClientRect(); const matrix = root.querySelector('.plan-svg')?.getScreenCTM(); const finalPoint = matrix ? new DOMPoint(stageRect.left + stageRect.width / 2, stageRect.top + stageRect.height / 2) .matrixTransform(matrix.inverse()) : null; const finalCenter = center(); return { executableGridDefaults: JSON.stringify(defaults) === JSON.stringify({ columns: 'full', rows: 10, min_rows: 6 }) && !Object.prototype.hasOwnProperty.call(defaults, 'max_rows') && card.getCardSize() === 12, gridSignalReflectsForScopedCss: card.getAttribute('layout') === 'grid', gridUsesContainerOwnedInlineHeight: defaultGeometry.stageStyle === 'auto', defaultTenRowsFillTheSlot: fillsSlot(defaultGeometry, rowHeight(10)), minimumSixRowsStayContainedInRuAndDe: minimumGeometry.length === 2 && minimumGeometry.every((entry) => fillsSlot(entry.sample, rowHeight(6)) && entry.headerContained && entry.menuReachable), ordinaryCardKeepsViewportHeight: ordinary.stageStyle.includes('100dvh'), explicitGridOptionsStayExternal: JSON.stringify(config) === configBefore && card._config.grid_options === config.grid_options, resizeChangesStageAtEveryRowCount: resizeHeights[0] < resizeHeights[1] && resizeHeights[1] < resizeHeights[2] && resizeHeights[3] === resizeHeights[0], resizePreservesCameraAndIntent: stableView && card._space === stableSpace && stableMode === 'view' && moreInfo === 0, allEditorsStayInsideTheFixedSlot: editorsContained, editorTransitionFramesUseTheFixedSlot: transitionsUseSlotHeight, editorTransitionsLeaveNoBusyTail: editorsSettle && card._mode === 'view' && !card._modeTransitionBusy, resizeStormSettlesWithoutLoop: fillsSlot(finalGeometry, rowHeight(10)) && stormElapsed < 3000 && Math.abs(finalGeometry.stage.width - settledStage.width) <= 0.5 && Math.abs(finalGeometry.stage.height - settledStage.height) <= 0.5, postResizeHitCoordinatesMatchCamera: finalPoint && near([finalPoint.x, finalPoint.y], finalCenter, 0.001) && Math.abs(card._view.w / card._view.h - finalGeometry.stage.width / finalGeometry.stage.height) < 0.001, }; }); checkAll(out); await finish(browser, out);