diff --git a/demo/smoke_render_perf.mjs b/demo/smoke_render_perf.mjs index c78bde99..f6bb5bf4 100644 --- a/demo/smoke_render_perf.mjs +++ b/demo/smoke_render_perf.mjs @@ -3,6 +3,27 @@ const { page, browser } = await launch(); const res = await page.evaluate(async () => { const out = {}; const c = window.__card; + const root = c.shadowRoot || c.renderRoot; + const frames = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + // #725 AC2: an update pass (willUpdate → render) builds the config + // fingerprint at most once, however often it reads `_model`. A floor switch + // through the real tab is the hot path the fingerprint used to dominate. + let fingerprintBuilds = 0; + const passBuilds = []; + const origFingerprint = c._cfgFingerprint.bind(c); + c._cfgFingerprint = (...a) => { fingerprintBuilds++; return origFingerprint(...a); }; // private-ok: счётчик сборок отпечатка за проход (#725) + const origWillUpdate = c.willUpdate.bind(c); + c.willUpdate = (...a) => { fingerprintBuilds = 0; return origWillUpdate(...a); }; // private-ok: счётчик сборок отпечатка за проход (#725) + const origRender = c.render.bind(c); + c.render = (...a) => { try { return origRender(...a); } finally { passBuilds.push(fingerprintBuilds); } }; // private-ok: счётчик сборок отпечатка за проход (#725) + const startSpace = c._space; + const otherTab = [...root.querySelectorAll('[data-hp="space-tab"]')].find((tab) => tab.dataset.id !== startSpace); + for (const id of [otherTab?.dataset.id, startSpace]) { + root.querySelector(`[data-hp="space-tab"][data-id="${id}"]`)?.click(); + await c.updateComplete; await frames(); await c.updateComplete; + } + out.floorSwitchRoundTrip = !!otherTab && c._space === startSpace && passBuilds.length >= 2; + out.fingerprintBuildsPerPass = Math.max(0, ...passBuilds); // #306: a hass tick that changes no geometry must not rebuild spaces. The // shared zero-wall resolver may run during paint, but must not storm. let zeroCalls = 0, buildCalls = 0; @@ -67,5 +88,6 @@ const res = await page.evaluate(async () => { checkAll(res, { modelBuildsPer10Renders: 0, clockTickModelBuilds: 0, + fingerprintBuildsPerPass: 1, }); await finish(browser, res); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 18f38b65..1fb25e02 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -508,6 +508,10 @@ edits — not a commit, not a merge (that is decided in `integrate` from the sea normal where wall bodies meet at a junction — leave that corner unsampled and the fan closes it with a chord, so a sliver of floor next to a corner the lamp plainly sees goes dark. `splitAtIntersections` removes the whole class. +- **Layout reads in the render path are judged by `scripts/render-layout-read.mjs`** + (`gate:small`, #654, #725): `getComputedStyle`, `getBoundingClientRect` and reads such as + `clientWidth`/`offsetTop`. The guarded methods (and the one summary-panel measurement method + that may read) are listed in the script; measure in `updated()` or an observer instead. ## Release diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 8bd15685..e5c8d273 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13843,6 +13843,35 @@ const MUTANT_DEFINITIONS = [ replace: " host.setAttribute('lang', lang); // mutant: written on every render\n", }], }, + // #725 AC5: принудительные layout в пути рендера — возврат ловит AST-гейт. + { + id: 'iso-scene-reads-stage-box-during-render', + guard: 'node scripts/render-layout-read.mjs', + because: '#725 AC5: _isoScene runs inside _renderBody through _effectiveProjection; reading ' + + 'the stage box there forces a synchronous layout on every 2.5D floor render, and the fit ' + + 'frame does not depend on the aspect since #713', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' const aspect = scene.frame.w / scene.frame.h; // #713/#725: the fit frame ignores aspect; no layout read in render\n', + replace: ' const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null;\n' + + ' const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h;\n', + }], + }, + { + id: 'summary-layout-reads-safe-insets-during-render', + guard: 'node scripts/render-layout-read.mjs', + because: '#725 AC5: the summary panel computes layout() up to five times per card render; ' + + 'reading the safe-area probe style there forces a style recalculation each time instead of ' + + 'using the insets measureLayout stored', + patches: [{ + file: 'src/summary-panel-runtime-loaded.ts', + find: ' return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n', + replace: " const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null;\n" + + ' const style = probe ? this.host.ownerDocument.defaultView?.getComputedStyle(probe) : undefined;\n' + + ' return resolveSummaryLayout({ ...this.stage, safeTop: Number.parseFloat(style?.paddingTop ?? \'\') || 0,\n' + + ' controlTop: this.host._kiosk ? this.stage.controlTop : 0 });\n', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/render-layout-read.mjs b/scripts/render-layout-read.mjs index b61018ea..8e4f42bb 100644 --- a/scripts/render-layout-read.mjs +++ b/scripts/render-layout-read.mjs @@ -1,10 +1,23 @@ #!/usr/bin/env node // #654: render and 2.5D pure helpers must not force browser layout. This is an // AST contract, not a regex anchor: formatting and comments cannot satisfy it. +// #725: a forced layout is a call (`getComputedStyle`, `getBoundingClientRect`) +// or a read of a layout property (`clientWidth`, `offsetTop`, …). The contract +// also judges `_isoScene` in the card (reached from `_renderBody` through +// `_effectiveProjection`) and the whole summary-panel runtime, whose render and +// lifecycle methods run on every card pass; only its one measurement method +// may read layout. import { readFileSync, readdirSync } from 'node:fs'; import ts from 'typescript'; -const forbidden = new Set(['getComputedStyle', 'getBoundingClientRect']); +const forbiddenCallNames = new Set(['getComputedStyle', 'getBoundingClientRect']); +const forbiddenReads = new Set([ + 'clientWidth', 'clientHeight', 'offsetWidth', 'offsetHeight', 'offsetTop', 'offsetLeft', + 'scrollWidth', 'scrollHeight', +]); +/** The summary-panel runtime method allowed to measure (#725). */ +const SUMMARY_MEASURE_METHOD = 'measureLayout'; + const source = (path) => ts.createSourceFile( path, readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, @@ -12,34 +25,64 @@ const source = (path) => ts.createSourceFile( const callName = (node) => !ts.isCallExpression(node) ? null : ts.isIdentifier(node.expression) ? node.expression.text : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : null; -const forbiddenCalls = (node) => { +const readName = (node) => ts.isPropertyAccessExpression(node) ? node.name.text + : ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) + ? node.argumentExpression.text + : ts.isBindingElement(node) && ts.isObjectBindingPattern(node.parent) + ? (node.propertyName ?? node.name).getText() : null; +const methodName = (node) => (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node)) + && node.name && ts.isIdentifier(node.name) ? node.name.text : null; +/** Forced-layout calls and reads under `node`, skipping subtrees `skip` accepts. */ +const layoutReads = (node, skip = () => false) => { const found = []; const visit = (child) => { - const name = callName(child); - if (name && forbidden.has(name)) found.push(name); + if (skip(child)) return; + const call = callName(child); + if (call && forbiddenCallNames.has(call)) found.push(call); + const read = readName(child); + if (read && forbiddenReads.has(read)) found.push(read); ts.forEachChild(child, visit); }; visit(node); return found; }; +const problems = []; const card = source('src/houseplan-card.ts'); -const required = new Set(['render', '_renderBody', 'willUpdate']); +const required = new Set(['render', '_renderBody', 'willUpdate', '_isoScene']); const checked = new Set(); const visit = (node) => { - if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name) - && required.has(node.name.text)) { - checked.add(node.name.text); - const calls = forbiddenCalls(node.body); - if (calls.length) throw new Error(`${node.name.text} forces layout: ${calls.join(', ')}`); + const name = methodName(node); + if (name && required.has(name) && ts.isMethodDeclaration(node)) { + checked.add(name); + const reads = layoutReads(node.body); + if (reads.length) problems.push(`${name} forces layout: ${reads.join(', ')}`); } ts.forEachChild(node, visit); }; visit(card); -if ([...required].some((name) => !checked.has(name))) throw new Error('guarded lifecycle method is missing'); +for (const name of required) if (!checked.has(name)) problems.push(`guarded method is missing: ${name}`); + +const summaryFile = 'src/summary-panel-runtime-loaded.ts'; +let measureMethods = 0; +const summaryReads = layoutReads(source(summaryFile), (node) => { + if (methodName(node) !== SUMMARY_MEASURE_METHOD) return false; + measureMethods += 1; + return true; +}); +if (measureMethods !== 1) { + problems.push(`${summaryFile}: expected one measurement method ${SUMMARY_MEASURE_METHOD}, found ${measureMethods}`); +} +if (summaryReads.length) { + problems.push(`${summaryFile} forces layout outside ${SUMMARY_MEASURE_METHOD}: ${summaryReads.join(', ')}`); +} for (const file of readdirSync(new URL('../src', import.meta.url)).filter((name) => /^iso-.*\.ts$/.test(name))) { - const calls = forbiddenCalls(source(`src/${file}`)); - if (calls.length) throw new Error(`${file} forces layout: ${calls.join(', ')}`); + const reads = layoutReads(source(`src/${file}`)); + if (reads.length) problems.push(`${file} forces layout: ${reads.join(', ')}`); +} +if (problems.length) { + for (const problem of problems) console.error(problem); + process.exit(1); } console.log('render/layout-read AST contract: OK'); diff --git a/src/config-fingerprint-pass.ts b/src/config-fingerprint-pass.ts new file mode 100644 index 00000000..90fb4e7e --- /dev/null +++ b/src/config-fingerprint-pass.ts @@ -0,0 +1,54 @@ +/** + * #725: the card's config fingerprint is built at most once per update pass. + * + * `_model` keys its memo on the config epoch plus a structural fingerprint that + * walks every space and room. One render reads `_model` dozens of times, and + * each read used to walk the whole house again. Inside one pass — from the + * start of `willUpdate()` to the end of `render()` — the first read builds the + * key with its fingerprint and the rest reuse that very string while the epoch, + * the config object and its `spaces` array stay the same. Remembering only the + * fingerprint and gluing the key anew on each read was tried first: on the large + * house in 2.5D it measured slower than no memo at all (more GC on load and on a + * first floor visit), while one remembered key is faster. + * + * Outside a pass (handlers, `updated()`, timers, async continuations) every + * read builds the fingerprint, exactly as before: that is where an in-place + * mutation without an epoch bump can happen, and the fingerprint is the belt + * that still sees it (HP-1454-04). + */ +export class ConfigFingerprintPass { + private serial = 0; + private open = false; + private memo: { epoch: number; config: object | null; spaces: unknown; value: string } | null = null; + + /** + * Opens a pass and forgets the previous one. A pass that never reaches + * `end()` — `willUpdate()` threw, so Lit never rendered — closes itself in a + * microtask, before any handler or timer could read a remembered fingerprint. + */ + public begin(): void { + const serial = ++this.serial; + this.open = true; + this.memo = null; + queueMicrotask(() => { if (this.serial === serial) this.end(); }); + } + + public end(): void { + this.open = false; + this.memo = null; + } + + /** + * What `build` returns for these inputs — the card passes its whole `_model` + * key, epoch and fingerprint — remembered inside a pass, built outside. + */ + public read(epoch: number, config: { spaces?: unknown } | null, build: () => string): string { + if (!this.open) return build(); + const spaces = config?.spaces; + const memo = this.memo; + if (memo && memo.epoch === epoch && memo.config === config && memo.spaces === spaces) return memo.value; + const value = build(); + this.memo = { epoch, config, spaces, value }; + return value; + } +} diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index a275ee94..a54d60df 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -301,6 +301,7 @@ import { HeaderMenu, headerMenuItems, renderHeaderActions } from './header-menu' import { isoWallMaterialVars, parseCssColor } from './iso-materials'; import { IsoFirstFrameState, isoPaperContext } from './iso-first-frame'; import { renderIsoTileShadow } from './iso-tiles'; import { displayVersion } from './card-version'; +import { ConfigFingerprintPass } from './config-fingerprint-pass'; const CARD_VERSION = '1.79.0-beta.1'; const EDITOR_RETRY_ASSET = '__HOUSEPLAN_EDITOR_RETRY_ASSET__'; const ISO_RETRY_ASSET = '__HOUSEPLAN_ISO_RETRY_ASSET__'; @@ -3770,6 +3771,7 @@ export class HouseplanCard extends LitElement { private _cfgEpochPreservedConfig: ServerConfig | null = null; private _terminalFrame: 0 | 1 | 2 = 0; // 1=restored cancel, 2=deferred HA private _modelCache: { key: string; model: SpaceModel[] } | null = null; + private readonly _cfgPass = new ConfigFingerprintPass(); // #725: one fingerprint per willUpdate→render pass private _emptySpaceStateActive = false; private _decorSnapCache: { epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry; @@ -3810,8 +3812,9 @@ export class HouseplanCard extends LitElement { private get _model(): SpaceModel[] { if (!this._serverCfg) return []; // In-place mutations mean the epoch can lag, so the key also carries the - // config's structural fingerprint. - const key = this._cfgEpoch + '|' + this._cfgFingerprint(); + // config's structural fingerprint. #725: a pass remembers the whole key; a kept + // fingerprint glued anew on every read measured slower in 2.5D than no memo. + const key = this._cfgPass.read(this._cfgEpoch, this._serverCfg, () => this._cfgEpoch + '|' + this._cfgFingerprint()); if (this._modelCache && this._modelCache.key === key) return this._modelCache.model; const built = this._buildModel(); this._modelCache = { key, model: built }; @@ -3998,7 +4001,7 @@ export class HouseplanCard extends LitElement { } protected willUpdate(changed: PropertyValues): void { - this._isoProjectionSnapshot = null; this._isoFirstFrame.prepare(this._desiredProjection, isoPaperContext(this._space, this._mode, !!this._spaceModel()?.bg, this.hass?.themes)); this._syncVolumetricSetting(); this._summary?.willUpdate(); + this._cfgPass.begin(); this._isoProjectionSnapshot = null; this._isoFirstFrame.prepare(this._desiredProjection, isoPaperContext(this._space, this._mode, !!this._spaceModel()?.bg, this.hass?.themes)); this._syncVolumetricSetting(); this._summary?.willUpdate(); if (changed.has('hass')) { // Observe every user/connection transition, including A→B→A while an // old promise is waiting. Equality at completion must not revive it. @@ -5995,14 +5998,13 @@ export class HouseplanCard extends LitElement { }); if (!space || !disp.showBorders) return scene; const cfgSize = this._config?.icon_size ?? 2.5, iconPct = cfgSize > 8 ? 2.5 : cfgSize; - const stageSize = this._stageEl?.getBoundingClientRect?.() ?? null; - const aspect = stageSize?.height ? stageSize.width / stageSize.height : scene.frame.w / scene.frame.h; + const aspect = scene.frame.w / scene.frame.h; // #713/#725: the fit frame ignores aspect; no layout read in render const overlays = this._isoOverlayScene( space, this._renderDevices.filter((device) => device.space === space.id && !device.hidden), disp, runtime.resolveIsoDecorationLayers(disp), scene, iconPct, effectiveDeviceBaseSize(iconPct), disp.showLqi ?? this._config?.show_signal ?? true); const envelope = overlays && runtime.resolveIsoOverlayFitEnvelope({ - baseBounds: scene.frame, entries: overlays.entries, stageSize, targetView: (bounds) => + baseBounds: scene.frame, entries: overlays.entries, stageSize: null, targetView: (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect) }); return envelope ? { ...scene, frame: envelope.bounds, overlayFitEntries: overlays.entries } : scene; } @@ -10568,12 +10570,14 @@ export class HouseplanCard extends LitElement { } protected render(): TemplateResult | typeof nothing | typeof noChange { - const body = this._renderBody(); - // `nothing` is the only root that has no decision surface. `noChange` is - // deliberately nested: it preserves the committed body while allowing the - // sibling hp-confirm to settle/cancel on a ready -> warm transition. - if (body === nothing) return body; - return this._renderRoot(body); + try { + const body = this._renderBody(); + // `nothing` is the only root that has no decision surface. `noChange` is + // deliberately nested: it preserves the committed body while allowing the + // sibling hp-confirm to settle/cancel on a ready -> warm transition. + if (body === nothing) return body; + return this._renderRoot(body); + } finally { this._cfgPass.end(); } // #725: the pass ends with render, before DOM commit and updated() } private _renderBody(): TemplateResult | typeof nothing | typeof noChange { diff --git a/src/summary-panel-runtime-loaded.ts b/src/summary-panel-runtime-loaded.ts index 7fd61df1..4e551a94 100644 --- a/src/summary-panel-runtime-loaded.ts +++ b/src/summary-panel-runtime-loaded.ts @@ -52,6 +52,10 @@ type SummaryLayoutState = { height: number; minimumHeight: number; controlTop: number; + safeLeft: number; + safeRight: number; + safeTop: number; + safeBottom: number; }; /** @@ -75,7 +79,10 @@ export class LoadedSummaryPanelRuntime { private storageKey: string | null = null; private stage: SummaryLayoutState = { width: 0, height: 0, minimumHeight: 162, controlTop: 0, + safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0, }; + /** #725: what the last measurement saw; `null` measures on the next `updated()`. */ + private measured: { key: string; elements: readonly (Element | null)[] } | null = null; private clock = new Date(); private clockTimer = 0; private deviceMemo: { @@ -115,6 +122,8 @@ export class LoadedSummaryPanelRuntime { this.connected = true; this.syncLifecycle(); this.loadLocal(); + this.measured = null; + this.measureAfterFonts(); } public disconnect(): void { @@ -143,6 +152,7 @@ export class LoadedSummaryPanelRuntime { this.host.requestUpdate(); return; } + this.measured = null; this.clock = new Date(); this.syncClock(); this.host.requestUpdate(); @@ -152,7 +162,7 @@ export class LoadedSummaryPanelRuntime { this.syncLifecycle(); this.syncNativeNarrow(); this.loadLocal(); - this.measureLayout(); + this.measureIfInputsChanged(); this.syncClock(); this.ensureDialogStyle(); this.presentation.updated(this.host.renderRoot.querySelector('.summary-overlay')); @@ -392,6 +402,8 @@ export class LoadedSummaryPanelRuntime { if (this.clockTimer) clearTimeout(this.clockTimer); this.clockTimer = 0; this.lifecycleIdentity = this.connected ? this.identity() : ''; + this.measured = null; + if (this.connected) this.measureAfterFonts(); this.host.requestUpdate(); } @@ -845,13 +857,9 @@ export class LoadedSummaryPanelRuntime { }).catch(() => undefined).finally(() => { this.metricsLoad = null; }); } + /** Render-time layout from the last measurement: no style or layout read (#725). */ private layout() { - return resolveSummaryLayout({ - width: this.stage.width, height: this.stage.height, - ...this.safeInsets(), - controlTop: this.host._kiosk ? this.stage.controlTop : 0, - minimumHeight: this.stage.minimumHeight, - }); + return resolveSummaryLayout({ ...this.stage, controlTop: this.host._kiosk ? this.stage.controlTop : 0 }); } private syncPresentation(immediate = false): void { @@ -866,37 +874,65 @@ export class LoadedSummaryPanelRuntime { immediate || !eligible || this.host.ownerDocument.visibilityState === 'hidden'); } + /** + * #725: the card calls `updated()` after every render — a floor switch, an HA + * tick — and measuring there forced a synchronous layout each time. Measure + * only when an input of the measurement changed: the probe and kiosk-button + * elements, the stage element, the title, language, mode, kiosk, its scale, + * `narrow` and the HA theme. The stage size arrives through `resized()`. + * Whatever is not listed and might matter resets `measured` instead. + */ + private measureIfInputsChanged(): void { + const { hass, renderRoot, _stageEl, _config, _mode, _kiosk, _kioskScale, narrow } = this.host; + const elements = [_stageEl, ...['.summary-measure', '.summary-safe-probe', '.summary-control.kiosk'] + .map((selector) => renderRoot.querySelector(selector))]; + const themes = hass?.themes as Record | undefined; + const key = JSON.stringify([ + this.config().config?.title ?? null, langOf(hass, _config?.language), _mode, _kiosk, _kioskScale, narrow, + hass?.selectedTheme ?? null, + ...['theme', 'darkMode', 'default_theme', 'default_dark_theme'].map((name) => themes?.[name] ?? null), + ]); + const last = this.measured; + if (last && last.key === key && last.elements.every((element, index) => element === elements[index])) return; + this.measured = { key, elements }; + this.measureLayout(); + } + + /** #725: a late web font changes the probe height without resizing the stage. */ + private measureAfterFonts(): void { + const generation = this.lifecycleGeneration; + void this.host.ownerDocument.fonts?.ready.then(() => { if (this.current(generation)) this.measureLayout(); }); + } + + /** + * The one method that reads style and layout (#725; `scripts/render-layout-read.mjs` + * names it): the stage, probe and kiosk-button boxes and the safe-area insets. + * Render paths use what it stored. + */ private measureLayout(): void { const stage = this.host._stageEl; if (!stage) return; - const probe = this.host.renderRoot.querySelector('.summary-measure') as HTMLElement | null; - const controls = this.host.renderRoot.querySelector('.summary-control.kiosk') as HTMLElement | null; + const root = this.host.renderRoot; + const probe = root.querySelector('.summary-measure') as HTMLElement | null; + const controls = root.querySelector('.summary-control.kiosk') as HTMLElement | null; + const safeProbe = root.querySelector('.summary-safe-probe') as HTMLElement | null; const stageBox = stage.getBoundingClientRect(); const controlBox = controls?.getBoundingClientRect(); - const next = { + const style = safeProbe ? this.host.ownerDocument.defaultView?.getComputedStyle(safeProbe) : undefined; + const px = (value: string | undefined): number => Number.parseFloat(value ?? '') || 0; + const next: SummaryLayoutState = { width: Math.round(stage.clientWidth), height: Math.round(stage.clientHeight), minimumHeight: Math.max(162, Math.ceil(probe?.getBoundingClientRect().height || 0)), controlTop: controlBox ? Math.max(0, Math.ceil(controlBox.bottom - stageBox.top + 12)) : 0, + safeLeft: px(style?.paddingLeft), safeRight: px(style?.paddingRight), + safeTop: px(style?.paddingTop), safeBottom: px(style?.paddingBottom), }; - if (next.width !== this.stage.width || next.height !== this.stage.height - || next.minimumHeight !== this.stage.minimumHeight || next.controlTop !== this.stage.controlTop) { + if ((Object.keys(next) as (keyof SummaryLayoutState)[]).some((name) => next[name] !== this.stage[name])) { this.stage = next; this.host.requestUpdate(); } } - private safeInsets(): { safeLeft: number; safeRight: number; safeTop: number; safeBottom: number } { - const probe = this.host.renderRoot.querySelector('.summary-safe-probe') as HTMLElement | null; - if (!probe) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 }; - const style = this.host.ownerDocument.defaultView?.getComputedStyle(probe); - if (!style) return { safeLeft: 0, safeRight: 0, safeTop: 0, safeBottom: 0 }; - const px = (value: string): number => Number.parseFloat(value) || 0; - return { - safeLeft: px(style.paddingLeft), safeRight: px(style.paddingRight), - safeTop: px(style.paddingTop), safeBottom: px(style.paddingBottom), - }; - } - private hasVisibleClock(): boolean { const config = this.config().config; if (this.host.ownerDocument.visibilityState === 'hidden' || !config) return false; diff --git a/test/config-fingerprint-pass.test.mjs b/test/config-fingerprint-pass.test.mjs new file mode 100644 index 00000000..d69c55da --- /dev/null +++ b/test/config-fingerprint-pass.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ConfigFingerprintPass } from '../test-build/config-fingerprint-pass.js'; + +/** A builder that counts how often the fingerprint is walked. */ +const counter = () => { + const built = { count: 0 }; + return { built, build: () => { built.count += 1; return `fingerprint-${built.count}`; } }; +}; + +test('#725 AC2: 50 reads inside one pass build the fingerprint once', () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + pass.begin(); + const values = Array.from({ length: 50 }, () => pass.read(3, config, build)); + pass.end(); + assert.equal(built.count, 1); + assert.deepEqual(new Set(values), new Set(['fingerprint-1']), 'every read gets the same fingerprint'); +}); + +test('#725 AC2: an epoch change inside the pass builds again', () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + pass.begin(); + pass.read(3, config, build); + pass.read(3, config, build); + // willUpdate bumps `_cfgEpoch` after its first `_model` read. + assert.equal(pass.read(4, config, build), 'fingerprint-2'); + pass.read(4, config, build); + pass.end(); + assert.equal(built.count, 2); +}); + +test('#725 AC2: a new config object or a new spaces array inside the pass builds again', () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + pass.begin(); + pass.read(3, config, build); + const replaced = { spaces: config.spaces }; + pass.read(3, replaced, build); + pass.read(3, replaced, build); + assert.equal(built.count, 2, 'a replaced config object'); + replaced.spaces = [{ id: 'floor-2' }]; + pass.read(3, replaced, build); + pass.read(3, replaced, build); + assert.equal(built.count, 3, 'a replaced spaces array'); + pass.read(3, null, build); + pass.read(3, null, build); + assert.equal(built.count, 4, 'no config at all'); + pass.end(); +}); + +test('#725 AC2: reads outside a pass build on every read', () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + for (let i = 0; i < 5; i++) pass.read(3, config, build); + assert.equal(built.count, 5, 'before the first pass: handlers, timers'); + pass.begin(); + pass.read(3, config, build); + pass.read(3, config, build); + pass.end(); + assert.equal(built.count, 6); + assert.equal(pass.read(3, config, build), 'fingerprint-7', 'after end() the remembered value is not used'); + pass.read(3, config, build); + assert.equal(built.count, 8, 'updated() and later handlers see an in-place mutation (HP-1454-04)'); +}); + +test('#725 AC2: a new pass forgets the previous one', () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + pass.begin(); + pass.read(3, config, build); + pass.begin(); + pass.read(3, config, build); + pass.end(); + assert.equal(built.count, 2, 'an in-place edit between passes is seen by the next pass'); +}); + +test('#725 AC2: a pass that never reaches end() closes itself in a microtask', async () => { + const pass = new ConfigFingerprintPass(); + const { built, build } = counter(); + const config = { spaces: [] }; + pass.begin(); // willUpdate() threw: Lit never calls render() + pass.read(3, config, build); + await null; + pass.read(3, config, build); + pass.read(3, config, build); + assert.equal(built.count, 3, 'a handler after the failed update builds on every read'); + + pass.begin(); + pass.end(); // pass A rendered normally; its microtask is still queued + let builtInsideB = -1; + queueMicrotask(() => { + const before = built.count; + pass.read(3, config, build); + pass.read(3, config, build); + builtInsideB = built.count - before; + }); + pass.begin(); // pass B opens before A's microtask runs + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(builtInsideB, 1, "A's microtask leaves pass B open"); + pass.read(3, config, build); + pass.read(3, config, build); + assert.equal(built.count, 6, "B's own microtask closes B"); +}); diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index 17cc3b1a..e514aa8f 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -26,6 +26,7 @@ import { projectIsoOpeningStructure, } from '../test-build/iso-openings.js'; import { buildIsoWallGeometry } from '../test-build/iso-walls.js'; +import { fitView } from '../test-build/logic.js'; import { wallKey } from '../test-build/wall-thickness.js'; import { buildIsoFootprintPolygon, @@ -194,6 +195,45 @@ test('#713 K8: overlay fit is the structure plus visible tiles, no #651 nudge re assert.deepEqual(otherRoom.bounds, { x: 0, y: 0, w: 100, h: 100 }); }); +test('#725 AC3: the overlay fit frame does not depend on the stage aspect or size', () => { + /** @type {OverlayEntryFixture[]} */ + const entries = [ + { + id: 'edge', kind: 'device', screenHalfSize: [10, 8], + placement: { + owner: { id: 'room' }, floorScene: [95, 50], visualScene: [95, 50], + footprint: [[90, 46], [100, 46], [100, 54], [90, 54]], + }, + }, + { + id: 'low', kind: 'device', screenHalfSize: [6, 6], + placement: { + owner: { id: 'room' }, floorScene: [40, 104], visualScene: [40, 104], + footprint: [[36, 100], [44, 100], [44, 108], [36, 108]], + }, + }, + ]; + const baseBounds = { x: 0, y: 0, w: 100, h: 100 }; + // The card's targetView: a contain-fit of the frame at the stage aspect. + const fitAt = (aspect) => (bounds) => fitView([bounds.x, bounds.y, bounds.w, bounds.h], aspect); + const results = []; + for (const aspect of [0.5, 2]) { + for (const stageSize of [null, { width: 1000, height: 500 }]) { + const fitted = resolveIsoOverlayFitEnvelope({ baseBounds, entries, stageSize, targetView: fitAt(aspect) }); + assert.ok(fitted, `aspect ${aspect}, stage ${JSON.stringify(stageSize)}: an envelope`); + results.push(fitted); + } + } + assert.deepEqual(results[0].bounds, { x: 0, y: 0, w: 105, h: 110 }, 'the structure plus both tiles'); + for (const fitted of results) assert.deepEqual(fitted.bounds, results[0].bounds); + assert.notDeepEqual(results[0].view, results[2].view, 'only the view follows the aspect'); + // `_isoScene` passes the frame's own aspect: any positive finite value gives the same bounds. + const ownAspect = resolveIsoOverlayFitEnvelope({ + baseBounds, entries, stageSize: null, targetView: fitAt(baseBounds.w / baseBounds.h), + }); + assert.deepEqual(ownAspect.bounds, results[0].bounds); +}); + test('one painter queue paints a nearer wall after an unrelated rear opening', () => { const geometry = buildIsoWallGeometry([[[ [0, 100], [100, 100], [100, 200], [0, 200], [0, 100], diff --git a/test/summary-panel-runtime.test.mjs b/test/summary-panel-runtime.test.mjs index 6c1bce0c..2965a2ca 100644 --- a/test/summary-panel-runtime.test.mjs +++ b/test/summary-panel-runtime.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import { nothing } from 'lit'; import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js'; -import { SUMMARY_PANEL_LEGACY_SCALE_KEY, summaryLocalKey } from '../test-build/summary-panel.js'; +import { SUMMARY_PANEL_LEGACY_SCALE_KEY, resolveSummaryLayout, summaryLocalKey } from '../test-build/summary-panel.js'; import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js'; const installBrowserGlobals = () => { @@ -386,3 +386,170 @@ test('#509 AC1/AC2/AC9: значение показывает скелет до browser.restore(); } }); + +/** + * #725 AC1: a host whose stage, probes, kiosk buttons and computed style count + * every forced-layout call. The measurement is the only reader: render paths + * and an `updated()` without a changed input read nothing. + */ +const measuredHostFixture = () => { + const host = hostFixture(); + const counts = { style: 0, box: 0, stageBox: 0 }; + let releaseFonts; + const box = (rect) => () => { counts.box++; return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, ...rect }; }; + const stage = { + clientWidth: 1000, clientHeight: 600, + getBoundingClientRect: () => { counts.stageBox++; return box({ top: 0, bottom: 600, width: 1000, height: 600 })(); }, + }; + const probe = { getBoundingClientRect: box({ height: 200 }) }; + const safeProbe = { padding: { paddingTop: '0px', paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px' } }; + const controls = { getBoundingClientRect: box({ top: 12, bottom: 58 }) }; + const view = { + getComputedStyle: (element) => { counts.style++; return element === safeProbe ? { ...safeProbe.padding } : {}; }, + }; + host.ownerDocument = { + ...host.ownerDocument, defaultView: view, + fonts: { ready: new Promise((resolve) => { releaseFonts = resolve; }) }, + }; + const overlay = { ownerDocument: host.ownerDocument }; + host._stageEl = stage; + host.renderRoot.querySelector = (selector) => ({ + '.summary-measure': probe, + '.summary-safe-probe': safeProbe, + '.summary-control.kiosk': host._kiosk ? controls : null, + '.summary-overlay': overlay, + })[selector] ?? null; + return { host, counts, safeProbe, releaseFonts: () => releaseFonts() }; +}; + +const installWindowTimers = () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'window'); + // The clock and the metrics scheduler need timers; nothing has to fire here. + Object.defineProperty(globalThis, 'window', { + configurable: true, value: { setTimeout: () => 1, requestAnimationFrame: () => 1 }, + }); + return () => { + if (previous) Object.defineProperty(globalThis, 'window', previous); + else delete globalThis.window; + }; +}; + +/** The rendered `--summary-*` values and the side class of one panel template. */ +const panelGeometry = (template) => { + assert.notEqual(template, nothing, 'the panel is mounted'); + const markup = template.strings.reduce((text, part, index) => text + part + + (index < template.values.length ? String(template.values[index]) : ''), ''); + const value = (name) => Number(new RegExp(`--summary-${name}:(-?[\\d.]+)px`).exec(markup)?.[1]); + return { + side: /class="summary-overlay (\w+)"/.exec(markup)?.[1], + heightCap: value('height-cap'), widthCap: value('width-cap'), top: value('top'), bottom: value('bottom'), + }; +}; + +test('#725 AC1: the summary panel measures only when an input of the measurement changes', async () => { + const browser = installBrowserGlobals(); + const restoreWindow = installWindowTimers(); + const { host, counts, releaseFonts } = measuredHostFixture(); + const runtime = new LoadedSummaryPanelRuntime(host); + try { + runtime.connect(); + runtime.saveLocal({ show: true }); + runtime.updated(); + assert.equal(counts.stageBox, 1, 'the first updated() after connect() measures'); + assert.equal(counts.style, 1, 'one computed style per measurement: the safe-area probe'); + + const steady = (label) => { + counts.style = 0; counts.box = 0; counts.stageBox = 0; + for (let cycle = 0; cycle < 20; cycle++) { + runtime.renderControls(false); + runtime.menuItems(); + runtime.renderPanel(); + runtime.updated(); + } + assert.deepEqual({ style: counts.style, box: counts.box }, { style: 0, box: 0 }, + `${label}: renders and updated() without a changed input read no style or layout`); + }; + steady('after the first measurement'); + assert.notEqual(runtime.renderPanel(), nothing, 'the oracle runs against a mounted panel'); + + const inputs = [ + ['resized()', () => runtime.resized()], + ['title', () => { + host._settings = { summary_panel: { ...runtime.config().config, title: 'Upstairs at a glance' } }; + }], + ['language', () => { host._config = { ...host._config, language: 'de' }; }], + ['_kiosk', () => { host._kiosk = true; }], + ['_kioskScale', () => { host._kioskScale = { icon: 1.5, font: 1.25 }; }], + ['narrow', () => { host.narrow = true; }], + ['HA theme', () => { host.hass = { ...host.hass, themes: { theme: 'midnight', darkMode: true } }; }], + ["visibility('visible')", () => runtime.visibility('visible')], + ['fonts.ready', async () => { releaseFonts(); await host.ownerDocument.fonts.ready; await null; }], + ]; + for (const [label, change] of inputs) { + counts.style = 0; counts.box = 0; counts.stageBox = 0; + await change(); + runtime.updated(); + assert.equal(counts.stageBox, 1, `${label}: exactly one measurement`); + assert.equal(counts.style, 1, `${label}: one getComputedStyle`); + assert.ok(counts.box <= 3, `${label}: at most three boxes (stage, kiosk buttons, probe), got ${counts.box}`); + steady(`after ${label}`); + } + } finally { + runtime.disconnect(); + restoreWindow(); + browser.restore(); + } +}); + +test('#725 AC1: fonts.ready after disconnect() does not measure', async () => { + const browser = installBrowserGlobals(); + const restoreWindow = installWindowTimers(); + const { host, counts, releaseFonts } = measuredHostFixture(); + const runtime = new LoadedSummaryPanelRuntime(host); + try { + runtime.connect(); + runtime.updated(); + runtime.disconnect(); + counts.style = 0; counts.box = 0; + releaseFonts(); + await host.ownerDocument.fonts.ready; + await null; + assert.deepEqual({ style: counts.style, box: counts.box }, { style: 0, box: 0 }); + } finally { + restoreWindow(); + browser.restore(); + } +}); + +test('#725 AC1: stored safe-area insets lay the panel out as resolveSummaryLayout does', () => { + const browser = installBrowserGlobals(); + const restoreWindow = installWindowTimers(); + const { host, safeProbe } = measuredHostFixture(); + const runtime = new LoadedSummaryPanelRuntime(host); + try { + runtime.connect(); + runtime.saveLocal({ show: true }); + safeProbe.padding = { paddingTop: '10px', paddingRight: '20px', paddingBottom: '30px', paddingLeft: '40px' }; + runtime.updated(); + const insets = { safeTop: 10, safeRight: 20, safeBottom: 30, safeLeft: 40 }; + for (const kiosk of [false, true]) { + host._kiosk = kiosk; + runtime.updated(); + const expected = resolveSummaryLayout({ + width: 1000, height: 600, minimumHeight: 200, controlTop: kiosk ? 70 : 0, ...insets, + }); + assert.deepEqual(panelGeometry(runtime.renderPanel()), { + side: expected.side, heightCap: Math.floor(expected.heightCap), + widthCap: Math.floor(expected.availableWidth), top: expected.top, bottom: expected.bottom, + }, kiosk ? 'kiosk: the panel clears the measured kiosk buttons' : 'ordinary View'); + } + assert.deepEqual(panelGeometry(runtime.renderPanel()).top, 70, 'kiosk buttons push the panel below them'); + host._kiosk = false; + runtime.updated(); + assert.deepEqual(panelGeometry(runtime.renderPanel()).top, 22, 'the top inset plus the 12 px gap'); + } finally { + runtime.disconnect(); + restoreWindow(); + browser.restore(); + } +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 3cb7d320..75e1e303 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -28,7 +28,7 @@ "src/space-order.ts", "src/card-editor-validation.ts", "src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts", "src/visual-continuity.ts", "src/version-recovery.ts", "src/version-recovery-card.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/boot-soft-layout.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", "src/touch-gesture-click-guard.ts", - "src/render-device-snapshot.ts", "src/render-invalidation.ts", "src/houseplan-render-lifecycle.ts", "src/live-interaction-runtime.ts", "src/live-viewport.ts", "src/live-hover.ts", "src/live-editor.ts", "src/pointer-move-queue.ts", + "src/render-device-snapshot.ts", "src/render-invalidation.ts", "src/config-fingerprint-pass.ts", "src/houseplan-render-lifecycle.ts", "src/live-interaction-runtime.ts", "src/live-viewport.ts", "src/live-hover.ts", "src/live-editor.ts", "src/pointer-move-queue.ts", "src/command-stack.ts", "src/device-position-history.ts", "src/coordinate-canonicalization.ts", "src/serialized-write-queue.ts",