// #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 { launch, checkAll, finish } from './serve.mjs'; const { page, browser } = await launch({ width: 320, height: 760 }); 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(); for (let attempt = 0; attempt < 50 && card._t('btn.cancel') !== expectedCancel; attempt++) { await new Promise((resolve) => setTimeout(resolve, 10)); await settle(); } const pending = card._confirmDanger(request(`locale-${language}`)); await settle(); const current = visible(); const fits = current.buttons[0]?.textContent.trim() === expectedCancel && current.surface.scrollWidth <= current.surface.clientWidth + 1 && current.footer.scrollWidth <= current.footer.clientWidth + 1; current.buttons[0].click(); await pending; 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; }); checkAll(out); await finish(browser, out);