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'); });