docs: specify the branch-independent danger confirmation (#402)

User-Visible: no
Issue: #402
This commit is contained in:
Codex
2026-08-31 16:08:52 +03:00
parent 4794183114
commit d94db87ee7
+167
View File
@@ -0,0 +1,167 @@
# ТЗ #402 — Подтверждение опасного действия живёт вне зависимости от ветки render()
- Issue: https://github.com/Matysh/houseplan-card/issues/402
- Приоритет: P1, bug (регресс против поведения до #32); полный трек — класс A
- Ревизия: 1 (2026-08-31)
## Сценарий
Хозяин ставит карточку в первый раз: пространств ещё нет, идёт онбординг. В
списке сохранённых на сервере планов лежит чужой, ошибочно загруженный файл —
он жмёт корзину. Ничего не происходит. Ни диалога, ни удаления, ни сообщения:
кнопка мертва, и понять почему нельзя. До #32 здесь спрашивал браузерный
`confirm()`, и удаление работало.
## Что человек увидит до и после
**До**: в состоянии «нет ни одного пространства» (а также при недоступном
`fixed_floor` и при потерянном пространстве) любое опасное действие молча не
делает ничего; если подтверждение было открыто до перехода в такое состояние —
диалог исчезает сам, а действие остаётся ни выполненным, ни отменённым.
**После**: подтверждение спрашивается и в этих состояниях; если карточка вообще
ничего не рисует, действие отклоняется явно, а не подвисает.
## Проблема и контракт
`HpConfirmController` (`src/danger-confirm.ts:36`) корректен: `cancel()`
резолвит промис `false`, `resolve()` сверяет токен, новый запрос отменяет
предыдущий. Промис виснет не потому, что контроллер теряет решение, а потому
что решение неоткуда взяться: оно приходит событием `hp-confirm-decision` от
элемента `<hp-confirm>`, а элемента в DOM нет.
`render()` (`src/houseplan-card.ts:11165`) — цепочка ранних `return`, и
`hp-confirm` стоит только в её конце (`:11872`):
| Строка | Ветка | Рисует |
|---|---|---|
| `:11166` | нет `_config`/`hass` | `nothing` |
| `:11170` | языковой гейт `cold` | загрузчик языка |
| `:11171` | языковой гейт `warm` | `noChange` |
| `:11192` | `fixed_floor` pending | своя карточка |
| `:11203` | `fixed_floor` invalid | своя карточка |
| `:11216` | `!model.length` (онбординг) | своя карточка |
| `:11247` | `!space` | `nothing` |
| `:11872` | основная | **единственное место с `hp-confirm`** |
Удаление сохранённого плана вызывается из онбординга
(`src/houseplan-onboarding-runtime.ts:273` → `_deleteServerPlan`, который
ждёт `_confirmDanger`), то есть буквальный сценарий issue лежит в ветке
`:11216`.
**Воспроизведено исполнением** (браузер, реальная карточка): очищаю `spaces`,
вызываю `_confirmDanger({...})` →
```
controllerHasRequest: true
confirmDialogInDom: false
promiseSettledOrDialogShown: false (ожидание 200 мс)
```
**Вторая половина того же дефекта** (в issue не описана, найдена при разборе):
уже открытое подтверждение исчезает, если карточка переходит в любую раннюю
ветку — потеряла пространство, ушла в `fixed_floor` pending. Пользователь
видит, как диалог пропал сам; вызывающий остаётся с неразрешённым промисом.
Поэтому «добавить рендер в ветку онбординга» — недостаточное решение: оно
чинит один вход и оставляет класс.
**Контракт**: `hp-confirm` не принадлежит ни одной ветке `render()`. Он
рендерится всегда, когда карточка рисует хоть что-то, и переживает смену
ветки. Разделение ответственности прежнее: контроллер владеет состоянием и
токеном, вызывающий — ревалидацией после `await` (введена #32 и не меняется).
**Граница**: две ветки рисуют «ничего» осознанно — `noChange` языкового гейта
(`:11171`, Lit-сигнал «не трогай DOM», его нельзя обернуть в шаблон) и
`nothing` до инициализации (`:11166`). В них подтверждение показать
невозможно, и правильное поведение — **отклонить запрос сразу** (промис
резолвится `false`), а не оставить его висеть. Пользователь в этот момент
ничего не нажимал: карточка ещё не готова.
## Скоуп / не-скоуп
**В скоупе**: структура `render()` в `src/houseplan-card.ts` (вынос
подтверждения из цепочки), поведение `_confirmDanger` в неготовых состояниях,
смоки и мутанты.
**Не в скоупе**: содержимое и оформление диалога (#32), ревалидация после
`await` у вызывающих (#32), роль `alertdialog` и `aria-describedby` (#406),
`_tapConfirm` и `_vacCalConfirm` — у них своя механика и свои ветки.
## UX
Видимых изменений в оформлении нет. Меняется только то, что подтверждение
появляется там, где раньше действие молча не работало.
## Модель данных и миграция
Не применимо.
## i18n
Новых строк нет.
## Критерии приёмки
- **AC1**. В состоянии «нет ни одного пространства» запрос подтверждения
показывает диалог; «Отмена» резолвит промис `false`, подтверждение — `true`.
Доказательство: браузерный смок на реальной карточке.
- **AC2**. Тот же инвариант в ветках `fixed_floor` pending и invalid и в ветке
`!space`. Доказательство: тот же смок, три ветки.
- **AC3**. Удаление сохранённого плана из онбординга доходит до конца:
подтверждение показано, после согласия план удалён. Доказательство: смок на
буквальном сценарии issue (`_deleteServerPlan`).
- **AC4**. Открытое подтверждение переживает смену ветки: карточка уходит в
`fixed_floor` pending при открытом диалоге — диалог остаётся, решение
по-прежнему резолвит промис. Доказательство: смок.
- **AC5**. В неготовых состояниях (`!_config || !hass`, языковой гейт `warm`)
запрос отклоняется немедленно: промис резолвится `false`, DOM не трогается.
Доказательство: юнит/смок с проверкой, что `noChange` из гейта не подменён
шаблоном.
- **AC6**. Существующее поведение основной ветки не изменилось: начальный фокус
на «Отмена», Esc отменяет, замена запроса отменяет предыдущий, вложенный
диалог не ломается. Доказательство: `demo/smoke_danger_confirmation.mjs`
остаётся зелёным без правок его утверждений.
- **AC7**. Бюджет initial не растёт (правка структурная). Доказательство:
`npm run bundle:budget` до и после.
## План автотестов
**Browser smoke** (`demo/smoke_danger_confirmation.mjs` — дополнение):
1. Пустая модель → `_confirmDanger` показывает диалог, «Отмена» → `false`
(AC1).
2. `fixed_floor` pending / invalid / `!space` → то же (AC2).
3. Онбординг: клик по корзине плана → диалог → согласие → план удалён (AC3).
4. Диалог открыт → карточка уходит в `fixed_floor` pending → диалог на месте,
решение резолвит промис (AC4).
5. Языковой гейт `warm` → запрос резолвится `false` сразу, `render()`
по-прежнему возвращает `noChange` (AC5).
**Мутант** (`scripts/mutation-gate.mjs`):
- `danger-confirm-back-into-the-branch`: вернуть `hp-confirm` внутрь основной
ветки → смок AC1 красный.
## Риски
- **`noChange` нельзя оборачивать.** Оборачивание сигнала в шаблон сломает
оптимизацию Lit и, вероятно, вызовет лишние перерисовки. Смягчение: ветки
`noChange`/`nothing` остаются нетронутыми, для них — явный отказ запроса;
AC5 это фиксирует.
- **Двойной рендер диалога.** Если подтверждение окажется и в общей обёртке, и
в основной ветке, получится два элемента и два источника событий. Смягчение:
смок проверяет ровно один `hp-confirm` в DOM.
- **Отказ в неготовом состоянии может удивить вызывающего.** Действие
отклоняется без участия пользователя. Смягчение: это состояние наступает до
того, как карточка вообще отрисована, то есть нажать было физически нельзя;
вызывающие уже обрабатывают `false` как «пользователь отказался».
## Откат
Возврат `hp-confirm` в основную ветку — одно перемещение блока. Данные
пользователя не затрагиваются.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что подтверждения
работают и до создания первого пространства (User-Visible: yes).
- Скриншоты не меняются: в статике диалог не открыт.