Files
houseplan-card/demo/smoke_warm_dialogs.mjs

338 lines
18 KiB
JavaScript

// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит;
// 6) (#756, раздел H) то же — когда право записи приходит позже вставки:
// hass после вставки или через такт, не-админ с `can_write` сервера.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись);
// раздел H — на сборке до #756 (черновик терялся, флаг ожидания висел).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
/** `order` (#756): `before` — hass до вставки; `after` — сразу после
* (порядок самого демо); `task` — через такт. */
const mk = (order = 'before', mkHass = window.__mkHass) => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
if (order === 'before') c.hass = mkHass();
wrap.appendChild(c);
if (order === 'after') c.hass = mkHass();
if (order === 'task') setTimeout(() => { c.hass = mkHass(); }, 0);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const waitFor = async (predicate, timeout = 2500) => {
const started = performance.now();
while (!predicate() && performance.now() - started < timeout) await sleep(20);
return predicate();
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const dialogSave = (card, kind) => (card.shadowRoot || card.renderRoot)
.querySelector(`hp-dialog[data-kind="${kind}"] [data-hp="dialog-confirm"]`);
/** The source instance must contribute its final painted viewport to the
* memo. Lazy editor chrome can deliver ResizeObserver after updateComplete;
* a fixed sleep occasionally captured that intermediate box and then asked
* the successor to preserve something the source itself would not keep. */
const waitForStableView = async (c, quietMs = 250, timeout = 2500) => {
const started = performance.now();
let stableAt = started;
let previous = '';
while (performance.now() - started < timeout) {
await new Promise((done) => requestAnimationFrame(done));
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const current = JSON.stringify({
mode: c._mode,
busy: c._modeTransitionBusy,
stage: stage ? [stage.clientWidth, stage.clientHeight] : null,
zoom: c._zoom,
view: rect(c),
});
if (current !== previous) { previous = current; stableAt = performance.now(); }
if (performance.now() - stableAt >= quietMs) return true;
}
return false;
};
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
const chrome = (c.shadowRoot || c.renderRoot).querySelector('.editorchrome');
bad.push({
t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c),
stage: stage ? [stage.clientWidth, stage.clientHeight] : null,
chrome: chrome ? [chrome.clientWidth, chrome.clientHeight, chrome.scrollHeight] : null,
hdrH: c._hdrH,
transition: [c._modeTransitionPreparing, c._modeTransitionBusy],
memo: c._warmSlot ? [c._warmSlot.hdrH, c._warmSlot.stageH] : null,
});
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: ${JSON.stringify(bad[0])} (ждали zoom=${zoom0} view=${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete;
out.aSourceSettled = await waitForStableView(c);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
await c._ensureEditorRuntime();
c._openSpaceDialog('edit', c._space); await c.updateComplete;
const spaceTitle0 = c._spaceDialog.title;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aDirtyBaselineSurvived = dialogSave(c, 'space')?.disabled === false;
c._spaceDialog = { ...c._spaceDialog, title: spaceTitle0 };
c.requestUpdate(); await c.updateComplete;
out.aRevertedToClean = dialogSave(c, 'space')?.disabled === true;
c.remove(); await sleep(20);
c = mk(); await waitFor(() => !!c._spaceDialog); await c.updateComplete; await sleep(80);
out.aCleanBaselineSurvived = dialogSave(c, 'space')?.disabled === true;
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
await c._requestMode('devices'); await c.updateComplete;
await waitFor(() => !c._modeTransitionBusy);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete;
out.bSourceSettled = await waitForStableView(c);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
const markerName0 = c._markerDialog.name;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
await waitFor(() => c._mode === 'devices' && !!c._markerDialog);
await c.updateComplete;
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
out.bDirtyBaselineSurvived = dialogSave(c, 'marker')?.disabled === false;
c._markerDialog = { ...c._markerDialog, name: markerName0 };
c.requestUpdate(); await c.updateComplete;
out.bRevertedToClean = dialogSave(c, 'marker')?.disabled === true;
c.remove(); await sleep(20);
c = mk(); await waitFor(() => c._mode === 'devices' && !!c._markerDialog);
await c.updateComplete;
out.bCleanBaselineSurvived = dialogSave(c, 'marker')?.disabled === true;
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
// Use the production preview instead of a partial synthetic report: report
// fields grow with the optimizer contract and the dialog renders all of them.
c._editorRuntime.optimizePlans.open();
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
// ================= E. baseline общих настроек: clean и dirty ============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._openSettingsDialog(); await c.updateComplete; await sleep(80);
out.eSettingsCleanBefore = dialogSave(c, 'settings')?.disabled === true;
c.remove(); await sleep(20);
c = mk();
const settingsCleanRevived = await waitFor(() => !!c._settingsDialog);
await c.updateComplete; await sleep(80);
out.eSettingsCleanAfter = settingsCleanRevived && dialogSave(c, 'settings')?.disabled === true;
if (!settingsCleanRevived) { c._openSettingsDialog(); await c.updateComplete; }
c._settingsDialog = {
...c._settingsDialog, showRoomTooltip: !c._settingsDialog.showRoomTooltip,
};
c.requestUpdate(); await c.updateComplete; await sleep(80);
out.eSettingsDirtyBefore = dialogSave(c, 'settings')?.disabled === false;
c.remove(); await sleep(20);
c = mk();
const settingsDirtyRevived = await waitFor(() => !!c._settingsDialog);
await c.updateComplete; await sleep(80);
out.eSettingsDirtyAfter = settingsDirtyRevived && dialogSave(c, 'settings')?.disabled === false;
c._settingsDialog = null; c.requestUpdate(); await c.updateComplete;
// ================= F. baseline комнаты: clean и dirty ===================
await c._requestMode('plan'); await c.updateComplete;
await waitFor(() => !c._modeTransitionBusy);
const room = c._curSpaceCfg?.rooms?.[0];
out.fRoomFixtureExists = !!room;
if (room) {
c._openRoomEdit(room); await c.updateComplete; await sleep(80);
const roomName0 = c._nameSel;
out.fRoomCleanBefore = dialogSave(c, 'room')?.disabled === true;
c.remove(); await sleep(20);
c = mk();
const roomCleanRevived = await waitFor(() => c._mode === 'plan' && c._roomDialog);
await c.updateComplete;
out.fRoomCleanAfter = roomCleanRevived && dialogSave(c, 'room')?.disabled === true;
if (!roomCleanRevived) { c._openRoomEdit(room); await c.updateComplete; }
c._nameSel = `${roomName0} warm`;
c.requestUpdate(); await c.updateComplete; await sleep(80);
out.fRoomDirtyBefore = dialogSave(c, 'room')?.disabled === false;
c.remove(); await sleep(20);
c = mk();
const roomDirtyRevived = await waitFor(() => c._mode === 'plan' && c._roomDialog);
await c.updateComplete;
out.fRoomDirtyAfter = roomDirtyRevived && dialogSave(c, 'room')?.disabled === false;
c._nameSel = roomName0; c.requestUpdate(); await c.updateComplete; await sleep(80);
out.fRoomRevertedToClean = dialogSave(c, 'room')?.disabled === true;
c.remove(); await sleep(20);
c = mk();
const roomRevertedRevived = await waitFor(() => c._mode === 'plan' && c._roomDialog);
await c.updateComplete;
out.fRoomCleanAfterRevert = roomRevertedRevived && dialogSave(c, 'room')?.disabled === true;
c._roomDialog = false; c.requestUpdate(); await c.updateComplete;
}
// ================= G. реальный уход с маршрута завершает редактор =======
await c._requestMode('devices'); await c.updateComplete; await sleep(60);
const returnUrl = `${location.pathname}${location.search}${location.hash}`;
const returnSpace = c._space;
const routeDev = c._devices.find((d) => d.space === returnSpace);
c._openMarkerDialog(routeDev); await c.updateComplete;
history.pushState({}, '', '/__houseplan-away__');
window.dispatchEvent(new CustomEvent('location-changed'));
await c.updateComplete;
out.gLiveDepartureEndsEditor = c._mode === 'view' && !c._markerDialog;
c.remove(); await sleep(20);
history.replaceState({}, '', returnUrl);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.gWarmReturnKeepsOnlySpace = c._space === returnSpace
&& c._mode === 'view' && !c._markerDialog;
// ================= H. право записи приходит позже вставки (#756) =========
// Новый экземпляр не может сразу вернуть редактор: hass ещё нет, или
// не-админ ждёт `can_write` сервера. Режим ждёт в `_pendingNavMode`, и
// черновик обязан вернуться вместе с ним — бит-в-бит, одноразово. Источник
// каждый раз холодный, а вход в редактор, диалог и ввод — через UI.
const nonAdmin = (configDelay = 0) => () => {
const hass = window.__mkHass();
const callWS = hass.callWS;
return {
...hass, user: { id: 'u', name: 'User', is_admin: false },
callWS: async (m) => {
if (configDelay && m.type === 'houseplan/config/get') await sleep(configDelay);
return callWS(m);
},
};
};
const root = (card) => card.shadowRoot || card.renderRoot;
const markerName = (card) => root(card).querySelector('hp-dialog[data-kind="marker"] #marker-name');
const devicesDraft = async (name) => {
c.remove(); await sleep(20);
customElements.get('houseplan-card')?._warmBootReset?.();
c = mk(); await settle(c);
root(c).querySelector('[data-hp="mode-tab"][data-mode="devices"]')?.click();
// источник сам выходит из мягкой фазы бута: до неё высота сцены ещё не своя
await waitFor(() => c._mode === 'devices' && !c._modeTransitionBusy && !c._bootSoft, 4000);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete;
await waitForStableView(c);
const ref = { zoom: c._zoom, view: rect(c) };
root(c).querySelector('[data-hp="device"][data-id]')?.click();
await waitFor(() => !!markerName(c));
if (markerName(c)) await window.__hpTest.input(markerName(c), name);
await waitForStableView(c);
return ref;
};
for (const [key, order, mkHass] of [
['hAfterInsert', 'after'], ['hNextTask', 'task'], ['hNonAdminCanWrite', 'before', nonAdmin()],
]) {
const ref = await devicesDraft(`ЧЕРНОВИК-${key}`);
out[`${key}Source`] = c._markerDialog?.name === `ЧЕРНОВИК-${key}` && ref.view[0] > 1 && ref.view[1] > 1;
c.remove(); await sleep(20);
c = mk(order, mkHass);
out[`${key}ViewBitExact`] = await watchView(c, ref.zoom, ref.view, 1500);
await waitFor(() => !!c._markerDialog);
await c.updateComplete;
out[`${key}ModeRestored`] = c._mode === 'devices';
out[`${key}DraftSurvived`] = c._markerDialog?.name === `ЧЕРНОВИК-${key}`;
out[`${key}DirtyBaselineSurvived`] = dialogSave(c, 'marker')?.disabled === false;
out[`${key}ReviveSettled`] = c._warmRevivePending === false;
if (key !== 'hAfterInsert') continue;
// цепочка: черновик, набранный уже в этом экземпляре, — следующий ре-маунт
// вернёт его, а не черновик предшественника
if (markerName(c)) await window.__hpTest.input(markerName(c), 'ЧЕРНОВИК-ЦЕПОЧКА');
await sleep(80);
c.remove(); await sleep(20);
c = mk();
await waitFor(() => c._mode === 'devices' && !!c._markerDialog);
out.hChainCarriesOwnDraft = c._markerDialog?.name === 'ЧЕРНОВИК-ЦЕПОЧКА';
}
// явная навигация в окне ожидания: другое пространство — черновик съеден,
// не открыт, и флаг ожидания не висит. Сервер отвечает через 400 мс, чтобы
// окно ожидания было, а не гонка.
await devicesDraft('ЧЕРНОВИК-ДРУГОЕ-ПРОСТРАНСТВО');
const draftSpace = c._space;
c.remove(); await sleep(20);
c = mk('before', nonAdmin(400));
await c.updateComplete; await sleep(50);
const otherSpace = c._model.find((sp) => sp.id !== draftSpace)?.id;
out.hOtherSpaceFixture = !!otherSpace && c._pendingNavMode === 'devices';
root(c).querySelector(`[data-hp="space-tab"][data-id="${otherSpace}"]`)?.click();
await waitFor(() => c._loadOk && c._space === otherSpace && !c._warmRevivePending, 3000);
await sleep(300); await c.updateComplete;
out.hSpaceSwitchNotRevived = c._space === otherSpace && c._mode === 'view' && !c._markerDialog;
out.hSpaceSwitchReviveSettled = c._warmRevivePending === false && c._warmSlot?.dlg === null;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);