From 00b6f41233fb94c1c2e84716dffe552fa1338668 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 31 Aug 2026 16:47:05 +0300 Subject: [PATCH] fix: the danger confirmation lives outside render()'s branches (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hp-confirm sat at the end of a chain of early returns, so in onboarding («no spaces yet»), in the fixed-floor states and without a space it did not exist at all: the trash button next to a saved plan was dead and the promise hung forever, because the decision event had no source in the DOM. An already open dialog vanished the moment the card slipped into one of those branches, leaving the caller waiting for a resolution that could never come. Before #32 a browser confirm() worked there. render() is now a wrapper: it takes the body — the old chain, unchanged, as _renderBody — and renders the confirmation beside it. That fixes the class rather than the instance: a branch added later cannot lose the dialog again. noChange and nothing are passed through untouched, since neither may be wrapped in a template; in those states _confirmDanger refuses the request outright instead of leaving it pending, which is the honest answer while the card is not on screen and the user has pressed nothing. _tapConfirm and _vacCalConfirm deliberately stay where they are. They share the same final branch, but they have no promise (a synchronous exec, a dialog closed by hp-close), so the defect cannot occur there, and their entry points require a drawn plan. Proven by a separate smoke rather than an addition to smoke_danger_confirmation: that file keeps deliberately incomplete dialog fixtures open, and the extra re-renders this change needs make them throw. The new smoke runs under touch emulation, because TOUCH-SUPPORT § Safety floor forbids bypassing a destructive confirmation and the broken branch pierced that floor on finger as surely as on mouse. Reverting the wrapper reddens it. User-Visible: yes Issue: #402 --- demo/smoke_danger_confirm_branches.mjs | 140 ++++++++++++++++++++ docs/CHANGELOG.md | 4 + docs/CHANGELOG.ru.md | 4 + scripts/mutation-gate.mjs | 12 ++ src/houseplan-card.ts | 50 +++++-- test/optional-space-model-contract.test.mjs | 14 +- 6 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 demo/smoke_danger_confirm_branches.mjs diff --git a/demo/smoke_danger_confirm_branches.mjs b/demo/smoke_danger_confirm_branches.mjs new file mode 100644 index 00000000..df07f0f3 --- /dev/null +++ b/demo/smoke_danger_confirm_branches.mjs @@ -0,0 +1,140 @@ +// #402: подтверждение опасного действия не принадлежит ни одной ветке +// render(). Раньше `hp-confirm` рендерился только в финальной ветке цепочки +// ранних return, и в онбординге («ещё нет ни одного пространства») кнопка +// удаления плана была мертва: диалог не появлялся, промис не разрешался +// никогда. Отдельный файл, а не дополнение smoke_danger_confirmation: +// там оснастка проверяет матрицу мутаций и держит открытыми неполные +// фикстуры диалогов, а здесь нужен чистый рендер. +import { launch, checkAll, finish } from './serve.mjs'; + +// Touch-эмуляция намеренно: `docs/TOUCH-SUPPORT.md` § Safety floor запрещает +// «best effort» в обходе подтверждения разрушающего действия, а сломанная +// ветка пробивала этот пол одинаково на мыши и на пальце. Проверяем там, где +// он обязан держаться. +const { page, browser } = await launch( + { width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true }, +); + +const out = await page.evaluate(async () => { + const result = {}; + const card = window.__card; + const root = () => card.shadowRoot || card.renderRoot; + const settle = async () => { + await card.updateComplete; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; + const dialogs = () => root().querySelectorAll('hp-confirm').length; + const request = (key) => ({ + key, + kind: 'destructive', + title: card._t('confirm.delete_plan_title'), + message: card._t('confirm.delete_plan_body'), + confirmLabel: card._t('btn.delete'), + cancelLabel: card._t('btn.cancel'), + }); + const decide = async (accepted) => { + const element = root().querySelector('hp-confirm'); + element?.dispatchEvent(new CustomEvent('hp-confirm-decision', { + detail: { token: card._dangerConfirm.token, accepted }, + bubbles: true, + composed: true, + })); + await settle(); + }; + + await settle(); + + // Основная ветка: ровно один диалог, а не два (риск двойного рендера при + // выносе — если бы блок остался и в ветке, и в обёртке). + const single = card._confirmDanger(request('single')); + await settle(); + result.mainBranchRendersExactlyOneConfirm = dialogs() === 1; + card._cancelDangerConfirm(); + result.mainBranchCancelResolvesFalse = (await single) === false; + + const savedCfg = card._serverCfg; + const savedModel = card._model; + const enterBranch = async (spaces) => { + card._serverCfg = { ...savedCfg, spaces }; + card._model = spaces.length ? savedModel : []; + card._cfgEpoch += 1; + card.requestUpdate(); + await settle(); + }; + const leaveBranch = async () => { + card._serverCfg = savedCfg; + card._model = savedModel; + card._cfgEpoch += 1; + card.requestUpdate(); + await settle(); + }; + + // Ветка онбординга — буквальный сценарий issue: здесь живёт корзина + // сохранённого плана. + await enterBranch([]); + result.onboardingBranchIsActuallyEntered = !root().querySelector('.stage'); + const onboarding = card._confirmDanger(request('onboarding')); + await settle(); + result.onboardingBranchShowsConfirm = dialogs() === 1; + await decide(false); + result.onboardingBranchResolves = (await onboarding) === false; + await leaveBranch(); + + // Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе + // с веткой, оставляя вызывающего с неразрешённым промисом. + const survivor = card._confirmDanger(request('survivor')); + await settle(); + const openedInMainBranch = dialogs() === 1; + await enterBranch([]); + const stillOpenInOnboarding = dialogs() === 1; + await decide(true); + result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding + && (await survivor) === true; + await leaveBranch(); + + // Тап по «Отмена» — тот же путь, что и клик: на touch подтверждение обязано + // спрашиваться, а не деградировать (TOUCH-SUPPORT § Safety floor). + await enterBranch([]); + const byTap = card._confirmDanger(request('touch')); + await settle(); + const tapDialog = root().querySelector('hp-confirm'); + // Кнопки живут в hp-dialog внутри hp-confirm; «Отмена» — первая в футере + // (она же с autofocus, см. #32 §6.1). + const footer = tapDialog?.renderRoot?.querySelector('.danger-confirm-footer') + || tapDialog?.shadowRoot?.querySelector('.danger-confirm-footer'); + const cancelButton = footer?.querySelector('button'); + const tapPoint = cancelButton?.getBoundingClientRect(); + if (cancelButton && tapPoint) { + for (const type of ['pointerdown', 'pointerup', 'click']) { + cancelButton.dispatchEvent(new PointerEvent(type, { + pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, + clientX: tapPoint.left + tapPoint.width / 2, + clientY: tapPoint.top + tapPoint.height / 2, + bubbles: true, composed: true, cancelable: true, + })); + } + } + await settle(); + result.touchTapOnCancelResolvesFalse = !!cancelButton && (await byTap) === false + && dialogs() === 0; + await leaveBranch(); + + // Соседние подтверждения остались в основной ветке и не поехали за компанию. + const source = card.constructor.toString?.() || ''; + result.neighbourConfirmsUntouched = card._tapConfirm === null + && card._vacCalConfirm === null && typeof source === 'string'; + + // Неготовая карточка отказывает сразу: подвесить запрос — это и есть дефект. + const savedHass = card.hass; + card.hass = undefined; + const refused = card._confirmDanger(request('not-ready')); + card.hass = savedHass; + card.requestUpdate(); + await settle(); + result.notReadyCardRefusesInsteadOfHanging = (await refused) === false; + + return result; +}); + +checkAll(out); +await finish(browser, out); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6dfe46f1..3b2ca806 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Confirmations now work before the first space exists: deleting a stored plan + during onboarding asks, and answers, instead of doing nothing + ([#402](https://github.com/Matysh/houseplan-card/issues/402)). + ## v1.70.0-beta.2 — 2026-08-31 - Moving a device or a directly placed entity to another Home Assistant Area diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index e596957d..421daaa8 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- Подтверждения работают и до создания первого пространства: удаление + сохранённого плана в онбординге теперь спрашивает и отвечает, а не молчит + ([#402](https://github.com/Matysh/houseplan-card/issues/402)). + ## v1.70.0-beta.2 — 2026-08-31 - Перенос устройства или отдельно размещённой сущности в другую HA-зону теперь diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a2a82a9d..05aed9c9 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -746,6 +746,18 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'danger-confirm-back-into-the-branch', + guard: 'node demo/smoke_danger_confirm_branches.mjs', + because: 'a confirmation that lives inside one branch of render() does not ' + + 'exist in onboarding — the trash button was dead and the promise hung ' + + 'forever (#402)', + patches: [{ + file: 'src/houseplan-card.ts', + find: ' return html`${body}${this._renderDangerConfirm()}`;', + replace: ' return html`${body}`;', + }], + }, { id: 'furniture-edge-handles-steal-the-corner', guard: 'node demo/smoke_furniture_polish.mjs', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 433df5be..6aa5c7e8 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -2150,8 +2150,15 @@ export class HouseplanCard extends LitElement { private readonly _dangerConfirmController = new HpConfirmController((state) => { this._dangerConfirm = state; }); - private _confirmDanger = (request: HpConfirmRequest): Promise => - this._dangerConfirmController.confirm(request); + private _confirmDanger = (request: HpConfirmRequest): Promise => { + // #402: a card that draws nothing cannot ask. Refusing outright is the + // honest answer — the alternative is a promise nobody will ever resolve, + // which is exactly the defect this issue closes. The caller already treats + // `false` as «the user declined», and at this point the user has pressed + // nothing: the card is not on screen yet. + if (!this._config || !this.hass) return Promise.resolve(false); + return this._dangerConfirmController.confirm(request); + }; private _cancelDangerConfirm = (): void => { this._dangerConfirmController.cancel(); }; @@ -11162,7 +11169,39 @@ export class HouseplanCard extends LitElement { } } + /** The dangerous-action confirmation, rendered outside the body's branches. + * + * #402: it used to sit at the end of `_renderBody`, i.e. inside the last + * branch of a chain of early returns — so in onboarding («no spaces yet»), + * in the fixed-floor states and without a space it did not exist at all. + * The controller was fine; the decision simply had no source, because the + * element that dispatches it was not in the DOM: the promise hung forever + * and the trash button next to a saved plan was dead. An open dialog also + * vanished when the card slipped into any of those branches. + * + * Placing it beside the body rather than inside it fixes the class, not the + * instance: a branch added later cannot lose the confirmation again. */ + private _renderDangerConfirm(): TemplateResult | typeof nothing { + if (!this._dangerConfirm) return nothing; + return html` + `; + } + protected render(): TemplateResult | typeof nothing | typeof noChange { + const body = this._renderBody(); + // `noChange` is Lit's «do not touch the DOM» signal and `nothing` means the + // card is not initialised yet — neither may be wrapped in a template. In + // both, the card draws nothing at all, so a confirmation cannot be shown: + // `_confirmDanger` refuses such a request outright instead of leaving it + // pending (ТЗ §Граница). The user has pressed nothing at that point. + if (body === noChange || body === nothing) return body; + return html`${body}${this._renderDangerConfirm()}`; + } + + private _renderBody(): TemplateResult | typeof nothing | typeof noChange { if (!this._config || !this.hass) return nothing; const localeGate = languageRenderGate( this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language), @@ -11869,13 +11908,6 @@ export class HouseplanCard extends LitElement { ` : nothing} - ${this._dangerConfirm - ? html` - ` - : nothing} ${this._toast ? html`` : nothing} `; diff --git a/test/optional-space-model-contract.test.mjs b/test/optional-space-model-contract.test.mjs index 0f8d8539..92ae84ac 100644 --- a/test/optional-space-model-contract.test.mjs +++ b/test/optional-space-model-contract.test.mjs @@ -64,10 +64,22 @@ test('stable space ids use exact lookup and abort before side effects', () => { }); test('empty render keeps create/import affordances without spatial layers', () => { - const render = methodBody('render'); + // #402: цепочка веток переехала из `render` в `_renderBody`, а `render` + // стал обёрткой — подтверждение опасного действия обязано жить снаружи + // веток, иначе в онбординге его не существует вовсе. + const render = methodBody('_renderBody'); const emptyAt = render.indexOf('if (!model.length)'); const addAt = render.indexOf("_openSpaceDialog('create')"); const spatialAt = render.indexOf('const space = this._spaceModel()'); assert.ok(emptyAt >= 0 && emptyAt < addAt && addAt < spatialAt); assert.match(render, /if \(!space\) return nothing;/); + + // Обёртка: тело + подтверждение, причём «ничего не рисуем» пробрасывается + // как есть — `noChange` нельзя оборачивать в шаблон. + const wrapper = methodBody('render'); + assert.match(wrapper, /const body = this\._renderBody\(\);/); + assert.match(wrapper, /if \(body === noChange \|\| body === nothing\) return body;/); + assert.match(wrapper, /return html`\$\{body\}\$\{this\._renderDangerConfirm\(\)\}`;/); + assert.equal(render.includes('_renderDangerConfirm'), false, + 'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402'); });