mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
perf(card): a floor switch stops forcing layout and re-walking the config (#725)
Profiling #694 found three costs on every View pass, paid even with the summary panel hidden. The summary panel read the safe-area probe's computed style in layout(), which the card reaches up to five times per render (renderControls, menuItems, renderPanel twice, the clock check), and its updated() measured the stage, probe and kiosk buttons after every DOM commit. The insets now live in the measured state: measureLayout is the only method that reads style or layout, and updated() calls it only when an input of the measurement changed (probe, kiosk buttons or stage element, title, language, mode, kiosk, kiosk scale, narrow, HA theme), after connect() or an identity change, on visibility, once after document.fonts.ready, and from resized() as before. A floor switch or an HA tick no longer measures. The _model getter rebuilt the config fingerprint (a walk over every space and room with JSON.stringify of room settings) on each of its dozens of reads per render. ConfigFingerprintPass remembers the whole cache key (epoch and fingerprint) from the start of willUpdate() to the end of render() while the epoch, the config object and its spaces array are unchanged. Remembering only the fingerprint and concatenating the key on every read was tried first: in 2.5D on the large house the switch cycle measured slower than without any memo, and CPU profiles showed several times more garbage collection on load and on the first visit of a floor; one remembered key per pass has neither. Outside the pass (handlers, updated(), timers) every read still builds the key, so an in-place edit without an epoch bump stays visible (HP-1454-04). No write to the fingerprinted fields is reachable from willUpdate() or render(). _isoScene read the stage box during render only to feed an aspect into the overlay fit, whose frame has not depended on the aspect since #713. It now uses the frame's own aspect and passes stageSize: null. render-layout-read.mjs now also judges _isoScene and the whole summary runtime except measureLayout, forbids layout property reads (clientWidth, offsetTop, ...) besides the two calls, and reports every violation. Two registered mutants restore the old reads. No visible change: panel caps, side, offsets and kiosk clearance are computed from the same values; the 2.5D frame is the same. Issue: #725 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -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 {
|
||||
|
||||
@@ -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<HTMLElement>('.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<string, unknown> | 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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user