mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Two halves of the same first paint. The panel showed «Source unavailable» in every row until the lazy metrics chunk arrived, because value() could not tell "not loaded yet" from "source is dead"; and metrics() ran inside render, walking the HA registry and unioning the clean floor of every space synchronously — 11 s on the large-house fixture. - totalCleanFloorAreaM2 computes the space's masonry and junction topology once per SPACE and hands them to innerContourForRoom, which otherwise unions the whole space again for every room: 11 045 → 1 488 ms on that fixture, same 306.3 m². The card has always done this through its own _innerContour cache; the panel now does the same. - Aggregates leave the render path: the first frame paints skeletons and the work starts right after the frame is shown (timeout → rAF → timeout, never requestIdleCallback, which under load would leave the skeleton up for seconds). Stale memo keeps the previous number on screen instead of flashing back to a skeleton. - valueState() separates pending from unavailable; a pending row keeps the same plate, grid and height and carries a pulsing rectangle the height of the line, replaced by the value with a short fade. Reduced motion keeps the rectangle and drops the pulse. - Panel enter/exit animation (#505, 190 ms) is now actually visible — the main thread is free — and the smoke witnesses it. Mutants: summary-first-paint-shows-unavailable, summary-metrics-block-first-frame, summary-area-recomputes-walls-per-room, summary-stale-metric-falls-back-to-skeleton. Issue: #509 User-Visible: yes
268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js';
|
|
import { 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();
|
|
Object.defineProperty(globalThis, 'location', {
|
|
configurable: true, value: { pathname: '/dashboard/home' },
|
|
});
|
|
Object.defineProperty(globalThis, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (key) => values.get(key) ?? null,
|
|
setItem: (key, value) => values.set(key, value),
|
|
},
|
|
});
|
|
return {
|
|
values,
|
|
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('#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,
|
|
};
|
|
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' });
|
|
|
|
// Расчёт после кадра: имитируем его завершение.
|
|
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();
|
|
}
|
|
});
|