Files
houseplan-card/test/summary-panel-runtime.test.mjs
T
Claudeandclaude[bot] 235f60e693 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
2026-10-01 03:17:40 +00:00

556 lines
24 KiB
JavaScript

import assert from 'node:assert/strict';
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, resolveSummaryLayout, summaryLocalKey } from '../test-build/summary-panel.js';
import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js';
const installBrowserGlobals = () => {
const previous = {
location: Object.getOwnPropertyDescriptor(globalThis, 'location'),
localStorage: Object.getOwnPropertyDescriptor(globalThis, 'localStorage'),
};
const values = new Map();
const reads = [];
const writes = [];
Object.defineProperty(globalThis, 'location', {
configurable: true, value: { pathname: '/dashboard/home' },
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key) => { reads.push(key); return values.get(key) ?? null; },
setItem: (key, value) => { writes.push(key); values.set(key, value); },
},
});
return {
values, reads, writes,
restore() {
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
},
};
};
const hostFixture = () => {
const root = {
adoptedStyleSheets: [], firstChild: null,
querySelector: () => null,
querySelectorAll: () => [],
insertBefore: () => undefined,
};
return {
localName: 'houseplan-card', parentNode: null,
hass: { user: { id: 'alice', name: 'Alice', is_admin: true }, states: {}, config: {} },
panelHost: true, narrow: false, renderRoot: root,
ownerDocument: {
defaultView: undefined, visibilityState: 'visible',
createElement: () => ({ dataset: {}, textContent: '' }),
},
isConnected: true, updateComplete: Promise.resolve(), requestUpdate: () => undefined,
_mode: 'view', _space: '', _settings: {}, _config: {}, _model: [], _markers: [],
_serverCfg: { spaces: [], markers: [], settings: {} }, _cfgRev: 0, _cfgEpoch: 0,
_layoutRev: 0, _cfgContentFingerprint: '', _regSignature: '',
_signer: { prepareImage: async () => true },
_continuity: { hasCompleteFrame: false, state: 'steady', note: () => undefined },
_writesPending: 0, _writeChain: Promise.resolve(), _kiosk: false,
_kioskScale: { icon: 1, font: 1 }, _haSummaryPanelApi: SUMMARY_PANEL_API_VERSION,
_canManageConfiguration: true,
_haRegistry: { authoritative: true, revision: 0, devices: {}, entities: {} },
_areaToSpace: {}, _stageEl: null,
_t: (key) => key, _errText: (error) => String(error),
_confirmDanger: async () => true, _sendConfigCandidate: async () => undefined,
_getAuthoritativeConfig: async () => ({ config: { spaces: [], markers: [], settings: {} }, rev: 0 }),
_candidateBackdrop: () => '', _scheduleLoadRetry: () => undefined,
_beginContinuityCandidate: () => 0, _adoptStructuralResponses: () => ({ configChanged: false }),
_syncDecorAssets: async () => undefined, _adoptInitialSpace: () => undefined,
_maybeRebuildDevices: () => undefined, _resumePendingNavMode: () => false,
_restoreZoom: () => undefined, _reloadConfigOnly: async () => undefined,
_cacheSnapshot: () => undefined,
};
};
test('#660 desktop summary controls stay available in editors while kiosk controls stay View-only', () => {
const host = hostFixture();
const runtime = new LoadedSummaryPanelRuntime(host);
for (const mode of ['plan', 'devices', 'decor']) {
host._mode = mode;
assert.notEqual(runtime.renderControls(false), nothing, `${mode}: desktop controls`);
assert.equal(runtime.renderControls(true), nothing, `${mode}: kiosk controls`);
}
});
test('#561 unresolved Masonry stays session-only and never imports an old DOM-path preference', () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
host.panelHost = false;
const masonry = { localName: 'hui-masonry-view', parentNode: null, children: [] };
const column = { localName: 'div', parentNode: masonry, children: [host] };
masonry.children = [column];
host.parentNode = column;
const oldKey = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace',
slot: 'hui-view:0/hui-masonry-view:0/div:0/houseplan-card:1',
});
const canonicalKey = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace', slot: 'masonry-v2:0',
});
browser.values.set(oldKey, JSON.stringify({
version: 1, show: true, icon_scale: 3, font_scale: 3,
}));
browser.values.set(SUMMARY_PANEL_LEGACY_SCALE_KEY, JSON.stringify({ icon: 1.2, font: 0.9 }));
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
assert.deepEqual(browser.reads, [], 'unresolved identity must not read any persistent fallback');
runtime.saveLocal({ show: true, icon_scale: 1.5 });
assert.equal(runtime.local.show, true, 'the preference remains effective for this session');
assert.deepEqual(browser.writes, [], 'unresolved identity must not write any persistent fallback');
masonry.cards = [host];
runtime.willUpdate();
runtime.loadLocal();
assert.deepEqual(runtime.local, {
version: 1, show: false, icon_scale: 1.2, font_scale: 0.9,
}, 'a new canonical key starts from the normal defaults and legacy scale seed');
assert.equal(browser.reads.includes(oldKey), false, 'the ambiguous legacy DOM key is not migrated');
assert.equal(browser.values.has(oldKey), true, 'upgrade leaves the old key untouched');
assert.deepEqual(browser.reads, [canonicalKey, SUMMARY_PANEL_LEGACY_SCALE_KEY]);
runtime.saveLocal({ show: false });
assert.deepEqual(browser.writes, [canonicalKey]);
assert.equal(JSON.parse(browser.values.get(canonicalKey)).show, false);
runtime.disconnect();
} finally {
browser.restore();
}
});
test('#561 identical Masonry cards reload their own local preferences', () => {
const browser = installBrowserGlobals();
const runtimes = [];
const mount = (columnIndexes) => {
const hosts = columnIndexes.flat().map(() => {
const host = hostFixture();
host.panelHost = false;
return host;
});
const masonry = { localName: 'hui-masonry-view', parentNode: null, children: [], cards: hosts };
const columns = columnIndexes.map((indexes) => {
const column = { localName: 'div', parentNode: masonry, children: [] };
column.children = indexes.map((index) => {
hosts[index].parentNode = column;
return hosts[index];
});
return column;
});
masonry.children = columns;
return hosts;
};
try {
const values = [
{ version: 1, show: true, icon_scale: 1.25, font_scale: 0.8 },
{ version: 1, show: false, icon_scale: 2.4, font_scale: 1.6 },
];
values.forEach((value, index) => browser.values.set(summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'lovelace', slot: `masonry-v2:${index}`,
}), JSON.stringify(value)));
const wide = mount([[0], [1]]);
const firstPass = wide.map((host) => {
const runtime = new LoadedSummaryPanelRuntime(host);
runtimes.push(runtime);
runtime.connect();
return runtime.local;
});
assert.deepEqual(firstPass, values);
runtimes.splice(0).forEach((runtime) => runtime.disconnect());
const narrow = mount([[0, 1]]);
const secondPass = narrow.map((host) => {
const runtime = new LoadedSummaryPanelRuntime(host);
runtimes.push(runtime);
runtime.connect();
return runtime.local;
});
assert.deepEqual(secondPass, values, 'new card objects after reload must not exchange preferences');
} finally {
runtimes.forEach((runtime) => runtime.disconnect());
browser.restore();
}
});
test('#493 same-key scale remains authoritative and identity changes discard the dialog', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
const key = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'panel', slot: 'houseplan-card',
});
browser.values.set(key, JSON.stringify({
version: 1, show: true, icon_scale: 2, font_scale: 1.5,
}));
const runtime = new LoadedSummaryPanelRuntime(host);
assert.equal(runtime.applyLocalScaleForCurrentIdentity(), true);
assert.deepEqual(host._kioskScale, { icon: 2, font: 1.5 });
host._kioskScale = { icon: 1, font: 1 };
assert.equal(runtime.applyLocalScaleForCurrentIdentity(), true);
assert.deepEqual(host._kioskScale, { icon: 2, font: 1.5 });
runtime.connect();
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true);
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
assert.equal(runtime.dialogOpen, false);
assert.deepEqual(host._kioskScale, { icon: 1, font: 1 });
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true);
host._kiosk = true;
runtime.updated();
assert.equal(runtime.dialogOpen, false, 'kiosk capability change discards the shared draft');
host._kiosk = false;
await runtime.openDialog();
runtime.leaveRoute();
assert.equal(runtime.dialogOpen, false, 'route leave discards the draft');
await runtime.openDialog();
runtime.disconnect();
assert.equal(runtime.dialogOpen, false);
runtime.connect();
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true, 'reconnect starts a fresh dialog');
} finally {
browser.restore();
}
});
test('#493 active picker keeps stable value ownership across reorder and closes on delete', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
const block = runtime.dialog.draft.blocks[0];
const targetId = block.values[0].id;
const otherId = block.values[1].id;
runtime.openSource(block.id, targetId);
runtime.mutate((draft) => { draft.blocks[0].values.reverse(); });
runtime.setSource(block.id, targetId, 'entity:sensor.target');
const target = runtime.dialog.draft.blocks[0].values.find((value) => value.id === targetId);
assert.deepEqual(target.source, { type: 'entity', entity_id: 'sensor.target' });
assert.equal(runtime.dialog.draft.blocks[0].values.at(-1).id, targetId,
'the active owner must follow the stable id after reorder');
runtime.openSource(block.id, otherId);
runtime.mutate((draft) => {
draft.blocks[0].values = draft.blocks[0].values.filter((value) => value.id !== otherId);
});
assert.equal(runtime.dialog.activeSource, null);
} finally {
browser.restore();
}
});
test('#493 a late save completion cannot adopt UI state into a new user generation', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
let releaseWrite;
let noteSent;
const sent = new Promise((resolve) => { noteSent = resolve; });
host._sendConfigCandidate = async () => {
noteSent();
await new Promise((resolve) => { releaseWrite = resolve; });
};
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
runtime.dialog.draft.title = 'Old user draft';
const saving = runtime.saveDialog();
await sent;
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
releaseWrite();
await saving;
assert.equal(runtime.dialogOpen, false);
assert.equal(host._serverCfg.settings.summary_panel, undefined,
'the server may accept an in-flight write, but the new UI must reload authority');
assert.equal(host._writesPending, 0);
} finally {
browser.restore();
}
});
test('#493 late reload and confirmation completions stay outside a new lifecycle', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
let releaseReload;
let reloadStarted;
host._reloadConfigOnly = async () => {
reloadStarted();
await new Promise((resolve) => { releaseReload = resolve; });
};
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
const started = new Promise((resolve) => { reloadStarted = resolve; });
const reloading = runtime.reloadDialog();
await started;
host._canManageConfiguration = false;
runtime.updated();
releaseReload();
await reloading;
assert.equal(runtime.dialogOpen, false);
host._canManageConfiguration = true;
runtime.updated();
await runtime.openDialog();
let releaseConfirm;
let confirmationStarted;
host._confirmDanger = async () => {
confirmationStarted();
return new Promise((resolve) => { releaseConfirm = resolve; });
};
const confirming = new Promise((resolve) => { confirmationStarted = resolve; });
const deleting = runtime.deleteBlock(0);
await confirming;
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
releaseConfirm(true);
await deleting;
assert.equal(runtime.dialogOpen, false);
} finally {
browser.restore();
}
});
test('#509 AC1/AC2/AC9: значение показывает скелет до готовности, ошибку — только для недоступного источника', () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
host._cfgEpoch = 1;
const runtime = new LoadedSummaryPanelRuntime(host);
const state = (value) => runtime.valueState(value);
const deviceCount = { id: 'v1', label: 'Devices', source: { type: 'system', key: 'device_count' } };
const entity = { id: 'v2', label: 'Lamp', source: { type: 'entity', entity_id: 'light.a' } };
// AC1: ленивый чанк ещё не пришёл — скелет, а не «источник недоступен».
assert.deepEqual(state(deviceCount), { kind: 'pending' });
assert.deepEqual(state(entity), { kind: 'pending' });
// Модуль пришёл, агрегат ещё не посчитан — по-прежнему скелет.
runtime.metricsModule = {
summaryEntityValue: (hass, id) => (id === 'light.a' ? 'On' : null),
summarySystemValue: (source, values) => (source.key === 'device_count'
? (values.deviceCount === null ? null : String(values.deviceCount)) : null),
representedHaDeviceIds: () => new Set(['d1', 'd2']),
totalCleanFloorAreaM2: () => 12.5,
// Порционный расчёт площади (#509): генератор, отдающий управление
// после каждой комнаты. Здесь — две порции, чтобы проверить и то, что
// незавершённый расчёт не считается готовым.
cleanFloorAreaSteps: function* steps() { yield; yield; return 12.5; },
};
assert.deepEqual(state(deviceCount), { kind: 'pending' });
// AC2: живая сущность отвечает сразу; отсутствующая — честная ошибка.
assert.deepEqual(state(entity), { kind: 'ready', text: 'On' });
assert.deepEqual(state({ ...entity, source: { type: 'entity', entity_id: 'light.gone' } }), { kind: 'unavailable' });
// Порция короче полного расчёта не делает значение готовым…
assert.equal(runtime.advanceMetrics(-1), false, 'бюджет исчерпан — расчёт продолжится');
assert.equal(runtime.metricsFresh(), false);
// …а полный проход — делает.
runtime.computeMetrics();
assert.deepEqual(state(deviceCount), { kind: 'ready', text: '2' });
// AC9: конфигурация изменилась — мемо устарело, но на экране остаётся
// прежнее число, а не скелет; пересчёт запланирован.
host._cfgEpoch = 2;
assert.equal(runtime.metricsFresh(), false);
assert.deepEqual(state(deviceCount), { kind: 'ready', text: '2' });
runtime.computeMetrics();
assert.equal(runtime.metricsFresh(), true);
} finally {
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();
}
});