mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
smoke_danger_confirmation asked for a confirmation as soon as btn.cancel was translated. With #627 the settings namespace of de/fr settles after the main catalog; while it is pending the host gate is warm and #417 refuses the request — the dialog never painted and the smoke crashed intermittently. The switch now waits until the host drops aria-busy (the gate settled), and the settings-de/fr chunks are delayed by 300 ms so the slower-namespace order is deterministic instead of a CI-speed coincidence. New checks name the lost dialog directly instead of a TypeError. Issue: #627 User-Visible: no
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
// #32: one House Plan confirmation surface owns destructive actions in View,
|
||
// onboarding and every editor. This smoke covers the real dialog contract,
|
||
// then exercises every migrated mutation class through both lazy runtimes.
|
||
import { readFileSync } from 'node:fs';
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch({ width: 320, height: 760 });
|
||
// #627 r1 M1: de/fr strings of the `settings` namespace are lazy chunks that
|
||
// settle after the main catalog. While one is pending the host language gate
|
||
// is `warm`, and #417 refuses/cancels a danger request in that state — a
|
||
// request made as soon as `btn.cancel` is translated used to be lost silently
|
||
// whenever the namespace chunk was the slower one. The delay makes that order
|
||
// deterministic instead of a CI-speed coincidence.
|
||
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
|
||
const delayedNamespaceChunks = ['de', 'fr'].map((language) => {
|
||
const name = (manifest.lazyNamespaceLocaleFiles || []).map((path) => path.split('/').at(-1))
|
||
.find((candidate) => candidate.startsWith(`settings-${language}-`));
|
||
if (!name) throw new Error(`namespace chunk settings-${language} is absent from the manifest`);
|
||
return name;
|
||
});
|
||
let delayedNamespaceServed = 0;
|
||
for (const name of delayedNamespaceChunks) {
|
||
await page.route(`**/${name}*`, async (route) => {
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
delayedNamespaceServed++;
|
||
await route.fallback();
|
||
});
|
||
}
|
||
const out = await page.evaluate(async () => {
|
||
const result = {};
|
||
const card = window.__card;
|
||
await card._ensureOnboardingRuntime();
|
||
const editor = card._editorRuntime;
|
||
const onboarding = card._onboardingRuntime;
|
||
const root = () => card.shadowRoot || card.renderRoot;
|
||
const settle = async () => {
|
||
await card.updateComplete;
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
};
|
||
const deepActive = () => {
|
||
let active = document.activeElement;
|
||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||
return active;
|
||
};
|
||
const request = (key = 'delete-plan') => ({
|
||
key,
|
||
kind: 'destructive',
|
||
title: card._t('confirm.delete_plan_title'),
|
||
message: card._t('confirm.delete_plan_body'),
|
||
objectName: 'A deliberately very long plan filename — Grundriss für das Dachgeschoss.png',
|
||
confirmLabel: card._t('btn.delete'),
|
||
cancelLabel: card._t('btn.cancel'),
|
||
});
|
||
const visible = () => {
|
||
const component = root().querySelector('hp-confirm');
|
||
const dialog = component?.querySelector('hp-dialog');
|
||
return {
|
||
component,
|
||
dialog,
|
||
native: dialog?.shadowRoot?.querySelector('dialog'),
|
||
surface: dialog?.shadowRoot?.querySelector('.surface'),
|
||
footer: dialog?.querySelector('.danger-confirm-footer'),
|
||
buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])],
|
||
};
|
||
};
|
||
|
||
const opener = document.createElement('button');
|
||
opener.textContent = 'danger action';
|
||
root().append(opener);
|
||
opener.focus();
|
||
const cancelled = card._confirmDanger(request());
|
||
await settle();
|
||
let ui = visible();
|
||
result.sharedDialogVisible = !!ui.component && !!ui.native?.open
|
||
&& ui.dialog?.title === card._t('confirm.delete_plan_title');
|
||
result.cancelHasInitialFocus = deepActive() === ui.buttons[0]
|
||
&& ui.buttons[0]?.hasAttribute('autofocus');
|
||
result.narrowContentFits = !!ui.surface && !!ui.footer
|
||
&& ui.surface.getBoundingClientRect().left >= -1
|
||
&& ui.surface.getBoundingClientRect().right <= innerWidth + 1
|
||
&& ui.surface.scrollWidth <= ui.surface.clientWidth + 1
|
||
&& ui.footer.scrollWidth <= ui.footer.clientWidth + 1
|
||
&& ui.buttons.every((button) => button.getBoundingClientRect().right <= innerWidth + 1);
|
||
ui.buttons[0].click();
|
||
result.cancelButtonIsSafe = await cancelled === false;
|
||
await settle();
|
||
result.cancelRestoresFocus = deepActive() === opener;
|
||
|
||
const accepted = card._confirmDanger(request());
|
||
await settle();
|
||
ui = visible();
|
||
ui.buttons[1].click();
|
||
result.acceptButtonResolvesOnce = await accepted === true;
|
||
|
||
const escaped = card._confirmDanger(request('escape'));
|
||
await settle();
|
||
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
|
||
key: 'Escape', bubbles: true, composed: true, cancelable: true,
|
||
}));
|
||
result.escapeIsSafe = await escaped === false;
|
||
|
||
const closed = card._confirmDanger(request('close'));
|
||
await settle();
|
||
visible().dialog.shadowRoot.querySelector('.close').click();
|
||
result.closeIsSafe = await closed === false;
|
||
|
||
const scrimmed = card._confirmDanger(request('scrim'));
|
||
await settle();
|
||
const native = visible().native;
|
||
native.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||
result.scrimIsSafe = await scrimmed === false;
|
||
|
||
const first = card._confirmDanger(request('first'));
|
||
const second = card._confirmDanger(request('second'));
|
||
await settle();
|
||
visible().buttons[1].click();
|
||
result.replacementCancelsOldRequest = await first === false && await second === true;
|
||
|
||
const localeFits = async (language, expectedCancel) => {
|
||
card._config = { ...(card._config || {}), language };
|
||
card.requestUpdate();
|
||
await settle();
|
||
// #627 r1 M1: the translated main catalog is not the whole language. The
|
||
// live switch holds the previous frame (inert + aria-busy) until every
|
||
// dictionary of the loaded surface settles; only then may a caller ask.
|
||
for (let attempt = 0; attempt < 250
|
||
&& (card._t('btn.cancel') !== expectedCancel || card.hasAttribute('aria-busy')); attempt++) {
|
||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||
await settle();
|
||
}
|
||
result[`${language}GateSettledBeforeAsking`] = card._t('btn.cancel') === expectedCancel
|
||
&& !card.hasAttribute('aria-busy');
|
||
const pending = card._confirmDanger(request(`locale-${language}`));
|
||
await settle();
|
||
const current = visible();
|
||
result[`${language}DialogShown`] = !!current.native?.open && !!current.buttons[0];
|
||
const fits = current.buttons[0]?.textContent.trim() === expectedCancel
|
||
&& !!current.surface && !!current.footer
|
||
&& current.surface.scrollWidth <= current.surface.clientWidth + 1
|
||
&& current.footer.scrollWidth <= current.footer.clientWidth + 1;
|
||
current.buttons[0]?.click();
|
||
result[`${language}CancelResolvesFalse`] = await pending === false;
|
||
return fits;
|
||
};
|
||
result.germanNarrowFits = await localeFits('de', 'Abbrechen');
|
||
result.frenchNarrowFits = await localeFits('fr', 'Annuler');
|
||
opener.remove();
|
||
|
||
// From here the UI promise is replaced with deterministic decisions so the
|
||
// action matrix can prove mutation ownership independently of layout.
|
||
const originalConfirm = card._confirmDanger;
|
||
let decision = false;
|
||
card._confirmDanger = async () => decision;
|
||
const baseSpace = () => ({
|
||
id: 'danger-space', title: 'Danger space', cell_cm: 5,
|
||
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
||
});
|
||
let configSaves = 0;
|
||
editor._saveConfigNow = async () => { configSaves++; };
|
||
card._serverCfg = {
|
||
spaces: [baseSpace()], settings: {},
|
||
markers: [{ id: 'danger-marker', binding: 'entity:switch.danger', space: 'danger-space' }],
|
||
};
|
||
card._devices = [];
|
||
// #404 AC5: диалог собирает сам продукт, а не фикстура.
|
||
//
|
||
// Ручная фикстура врала: у объявленного типа больше сорока обязательных
|
||
// полей, и рендер падал на первом же недостающем — сначала
|
||
// `_bindingHasHaPage(undefined).split(':')`, а после добавления binding и
|
||
// bindingMode на следующем. Дописывать поля по одному значит гоняться за
|
||
// типом; штатный путь «новый маркер» отдаёт полный объект с дефолтами
|
||
// продукта, и переопределить остаётся ровно то, что нужно этой проверке.
|
||
//
|
||
// Дефекта поведения здесь нет и не было: все 15 мест в src/, создающих
|
||
// диалог, идут этим же путём. Врал только смок — и гард этого не видел.
|
||
editor._openMarkerDialog();
|
||
card._markerDialog = {
|
||
...card._markerDialog,
|
||
devId: 'danger-marker', name: 'Danger marker',
|
||
binding: 'entity:switch.danger', bindingMode: 'ha', busy: false,
|
||
};
|
||
decision = false;
|
||
await editor._deleteMarker();
|
||
const markerCancel = card._serverCfg.markers.length === 1 && configSaves === 0;
|
||
decision = true;
|
||
await editor._deleteMarker();
|
||
result.markerCancelAccept = markerCancel && card._serverCfg.markers.length === 1
|
||
&& card._serverCfg.markers[0].id === 'danger-marker'
|
||
&& card._serverCfg.markers[0].removed === true
|
||
&& card._serverCfg.markers[0].hidden === true && configSaves === 1;
|
||
|
||
const wsCalls = [];
|
||
card.hass = {
|
||
...card.hass,
|
||
callWS: async (message) => {
|
||
wsCalls.push(message);
|
||
if (message.type === 'houseplan/config/get') return { config: card._serverCfg, rev: 1 };
|
||
if (message.type === 'houseplan/layout/get') return { layout: card._layout, rev: 1 };
|
||
return {};
|
||
},
|
||
};
|
||
const setPlanDialog = () => {
|
||
card._spaceDialog = {
|
||
mode: 'edit', spaceId: 'danger-space', title: 'Danger space', planUrl: null,
|
||
planFile: null, source: 'draw', showBorders: true, showNames: true,
|
||
zeroWallStyle: 'dashed', displayTouched: true, hideDecor: false,
|
||
hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
|
||
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
|
||
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
|
||
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
|
||
busy: false, saved: [{ name: 'old.png', url: '/old.png', size: 1, modified: 1, used_by: [] }],
|
||
};
|
||
};
|
||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||
setPlanDialog();
|
||
decision = false;
|
||
const before = wsCalls.length;
|
||
await runtime._deleteServerPlan('old.png');
|
||
const cancelSafe = wsCalls.length === before;
|
||
decision = true;
|
||
await runtime._deleteServerPlan('old.png');
|
||
result[`${name}PlanCancelAccept`] = cancelSafe
|
||
&& wsCalls.filter((call) => call.type === 'houseplan/plans/delete').length
|
||
=== (name === 'editor' ? 1 : 2);
|
||
}
|
||
|
||
card._saveConfigDebounced.cancel();
|
||
card._persistLayout.cancel();
|
||
card._writeChain = Promise.resolve();
|
||
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
|
||
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
|
||
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
|
||
card._space = 'another-space';
|
||
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
|
||
setPlanDialog();
|
||
decision = false;
|
||
const before = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length;
|
||
await runtime._deleteSpace();
|
||
const cancelSafe = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before;
|
||
decision = true;
|
||
await runtime._deleteSpace();
|
||
result[`${name}SpaceCancelAccept`] = cancelSafe
|
||
&& wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before + 1;
|
||
}
|
||
|
||
const services = [];
|
||
card._openingEntityAvailable = () => true;
|
||
card._openingInfo = {
|
||
id: 'door', type: 'door', rx: 0.5, ry: 0.5, len_cm: 90, lock: 'lock.danger',
|
||
};
|
||
card.hass = {
|
||
...card.hass,
|
||
states: {
|
||
...card.hass.states,
|
||
'lock.danger': { entity_id: 'lock.danger', state: 'locked', attributes: { friendly_name: 'Door' } },
|
||
},
|
||
callService: async (...args) => { services.push(args); },
|
||
};
|
||
decision = false;
|
||
await card._lockAction('lock.danger', 'unlock');
|
||
const unlockCancel = services.length === 0;
|
||
decision = true;
|
||
await card._lockAction('lock.danger', 'unlock');
|
||
result.unlockCancelAccept = unlockCancel && services.length === 1
|
||
&& services[0][0] === 'lock' && services[0][1] === 'unlock';
|
||
|
||
card._confirmDanger = originalConfirm;
|
||
return result;
|
||
});
|
||
// The delay is only proof if the delayed chunks were really on the path.
|
||
out.delayedNamespaceChunksServed = delayedNamespaceServed === delayedNamespaceChunks.length;
|
||
|
||
checkAll(out);
|
||
await finish(browser, out);
|