Files
houseplan-card/docs/reviews/CODE-REVIEW-32-r1.md
T
2026-08-30 22:06:44 +00:00

21 KiB
Raw Blame History

CODE-REVIEW #32 · r1 — единое подтверждение опасных действий

  • Issue: https://github.com/Matysh/houseplan-card/issues/32
  • Этап: code (PROCESS.md §2.7)
  • Заход: r1 · блокирующих циклов израсходовано 0 из 4
  • Материал: git log --oneline origin/dev..HEAD, git diff origin/dev...HEAD
  • ТЗ: docs/specs/032-unified-danger-confirmation.md (spec-review зелёный на r2, SHA 206bcd1a, документы docs/reviews/SPEC-REVIEW-32-r{1,2}.md)
  • Это первый раунд код-ревью — предыдущего кода-раунда нет, разбор полный, разделы «Унаследовано из r0» и «Закрытие r0» не нужны.

Скоуп диффа

origin/dev...HEAD, 57 файлов, ключевые продуктовые:

  • src/danger-confirm.ts (новый) — HpConfirmController, replace-not-queue, token-based resolve.
  • src/hp-confirm.ts (новый) — presentation поверх hp-dialog.
  • src/houseplan-card.ts — controller-owner, host API _confirmDanger/ _cancelDangerConfirm, cancel на смене space/mode/route/disconnect, unlock call site.
  • src/houseplan-editor-runtime.ts, src/houseplan-onboarding-runtime.ts — миграция 7 из 8 call sites (draft whole/segment, marker, plan×2, space×2).
  • src/i18n/{en,ru,de,fr}.json — новые title/body ключи.
  • src/styles/dialogs.styles.ts — стили .danger-confirm-*.
  • test/danger-confirmation.test.mjs (новый), правки test/render-device-snapshot.test.mjs, tsconfig.test.json.
  • demo/smoke_danger_confirmation.mjs (новый), правки demo/smoke_lock_action.mjs, demo/smoke_lock_invariant.mjs, demo/smoke_saved_plans.mjs.
  • Документация: docs/ARCHITECTURE.md, docs/USER-GUIDE.ru.md, оба CHANGELOG, сгенерированное дерево бандла (dist + custom_components + demo/srv).

Основа сверена читением: все 8 call sites §4.1 ТЗ найдены в диффе как await this._confirmDanger(...) / await this.host._confirmDanger(...); прямых вызовов confirm( в src/** не осталось (перепроверено rg и собственным прогоном test/danger-confirmation.test.mjs).

Как проверялось

Зелёного Validate на SHA 7b363caa нет — все гейты ниже прогнаны мной лично в рабочей копии на этом SHA.

Дешёвые гейты (прогнаны)

Команда Результат
npx tsc --noEmit чисто, без вывода
npm test tests 1652 · pass 1651 · fail 0 · skipped 1
npm run bundle:sync build ok; git status --porcelain после — пусто (три дерева синхронны)
npm run bundle:budget initial View: 285355 B gzip (budget 300000 B, headroom 14645 B) — совпадает с заявкой автора (+1300 B к baseline 284055, ≤3 KiB по AC-13)
node scripts/check-docs.mjs Documentation checks passed (7 files, 10 external links)
node scripts/no-new-any.mjs --base origin/dev --head HEAD Новых any нет (379 добавленных строк в 6 файлах)

npm run invariants не прогонял: дифф не трогает рёбра/толщину/layout/ marker.space/open_spans — только диалоговый слой и call sites, геометрия не затронута.

npm run golden:verify не прогонял: новый диалог не входит в golden-матрицу, и diff не показывает изменения существующего baseline (только служебные скриншоты документации, см. ниже).

python -m pytest tests_backend -q не прогонял: custom_components/**/*.py не тронут (только сгенерированное frontend/*).

Performance-профили не прогонял: не названы в AC, чувствительные к перфу пути не затронуты.

Browser smoke — выбор и результат

node scripts/smoke-select.mjs --base origin/dev --head HEAD: матрица 209 смоков, 39 «прямое совпадение», 81 «слабая связь» (остальное неопределённость). Прямые совпадения в основном объясняются тем, что дифф трогает общие поля хоста (_mode, _markerDialog, _spaceDialog, _physicalDialog, _curSpaceCfg, _openingInfo, _commitSpace) в функциях-обёртках (_setMode, _commitSpace, _editorSecondaryDialogBlocked), а не потому что эти смоки проверяют подтверждение как таковое.

Прогнал (актуальный контракт диалога — cancel/accept/replace/focus/touch, уже прогнанные автором плюс независимая перепроверка):

smoke_danger_confirmation, smoke_esc_dialogs, smoke_dialog_footer_width, smoke_toggle_confirmation, smoke_opening_binding, smoke_lock_action, smoke_lock_invariant, smoke_saved_plans, smoke_warm_dialogs, smoke_editor_tabs — все 10 зелёные (полный JSON output эквивалентен заявке автора).

Дополнительно прогнал прямые совпадения на _physicalDialog/_openingInfo, которых не было в списке автора, потому что ровно они называют мигрированные call sites (_deleteDraftWhole/_deleteDraftSegment, _lockAction): smoke_free_walls, smoke_registryless_opening, smoke_inert_openings, smoke_open_passage, smoke_lazy_editor_chunk (AC-10).

Это вскрыло H1 (ниже): smoke_free_walls и smoke_registryless_opening падают. Разбирая причину, прошёлся по всем демо-смокам, которые вообще вызывают один из мигрированных методов (grep -rl "_deleteMarker\|_deleteSpace()\|_deleteServerPlan\|_deleteDraftWhole\|_deleteDraftSegment\|_deletePhysicalSelection\|_lockAction(" demo/smoke_*.mjs → 11 файлов) и отдельно все, что стабили window.confirm (grep -rl "window\.confirm\s*=" demo/smoke_*.mjs → 6 файлов), чтобы отделить не задетые (lock-only, partition/column-kind, blocked-путь) от реально сломанных. Результат — раздел «Находки» ниже.

Остальные 29 прямых совпадений и все 81 слабых не прогонял: по чтению кода дифф трогает эти поля только через общий _editorSecondaryDialogBlocked/ _cancelDangerConfirm — они не меняют логику, которую эти смоки проверяют (room dialog, merge/split, tap confirm, vac calibration, палитра мебели и т.п.), а не потому что «выбор по теме» — их предмет прямо не пересекается ни с одним из 8 мигрированных call sites (подтверждено grep выше: только перечисленные 11 файлов вообще вызывают эти методы).

Находки

H1 — миграция ломает существующие смоки: 2 дают неверный вердикт, 3 виснут навсегда

Файлы: demo/smoke_free_walls.mjs, demo/smoke_registryless_opening.mjs, demo/smoke_binding_picker.mjs, demo/smoke_hidden_flag.mjs, demo/smoke_optional_space_model.mjs.

Задача заменяет синхронный confirm() на await this._confirmDanger(...), который резолвится только реальным кликом по кнопке диалога. Дифф обновил под это ровно 3 существующих смока (smoke_lock_action.mjs, smoke_lock_invariant.mjs, smoke_saved_plans.mjs — видно в git diff origin/dev...HEAD --stat), но в дереве есть ещё 5 смоков, которые вызывают один из тех же восьми call sites и по-старому либо стабят window.confirm = () => true без взаимодействия с диалогом, либо не ждут промис вовсе. Воспроизведено исполнением, не догадкой:

$ node demo/smoke_free_walls.mjs
FAILED (2):
  - deleteOnDraftRemovesWholeOutline: expected true, got false
  - invalidThicknessCreatesNothing: expected true, got false

$ node demo/smoke_registryless_opening.mjs
FAILED (1):
  - explicitInfoActionStillWorks: expected true, got false

$ timeout 25 node demo/smoke_binding_picker.mjs
Terminated            # процесс не завершается сам, требуется kill

$ timeout 25 node demo/smoke_hidden_flag.mjs
Terminated

$ timeout 25 node demo/smoke_optional_space_model.mjs
Terminated

Причина видна в исходниках смоков:

  • smoke_free_walls.mjs:13 ставит window.confirm = () => true;, затем синхронно вызывает c._deletePhysicalSelection() на draft-выборе (строки 195–197) и тут же читает результат — _deletePhysicalSelection для kind: 'draft' делает this._deleteDraftWhole(); return; без await (src/houseplan-editor-runtime.ts:3072), поэтому реальное удаление происходит позже, после клика по несуществующей кнопке — который никогда не случается.
  • smoke_registryless_opening.mjs:89-91 тем же способом стабит window.confirm и вызывает card._lockAction(lockId, 'unlock') без await, читая serviceCalls.at(-1) сразу же.
  • smoke_binding_picker.mjs:33-34, smoke_hidden_flag.mjs:83-84,96-97, smoke_optional_space_model.mjs:91-93 ждут промис (await c._deleteMarker() / await card._deleteSpace()), но промис _confirmDanger резолвится только явным hp-confirm-decision, а стаб window.confirm больше никем не читается — сцена не диспатчит клик, дизконнект или замену запроса, поэтому await виснет бесконечно. Три файла проверял отдельно — во всех трёх процесс не завершается, finish() (demo/serve.mjs:35, выставляет process.exitCode = 1 при провале) вообще не достигается. Это хуже явного провала: в CI такой смок не падает быстро, а занимает слот до тайм-аута раннера.

Не задеты (проверено, не потребовалось чинить): smoke_orphan_space_references.mjs стабит window.confirm и ждёт _deleteSpace(), но сценарий останавливается на blocker-проверке до _confirmDanger (deleteExplainsBlockerWithoutConfirmOrWrite), поэтому не виснет — прогнан, зелёный. smoke_opening_binding.mjs и smoke_partition_openings.mjs вызывают _lockAction(..., 'lock') (не 'unlock') и _deletePhysicalSelection() на partition/column-выборе — ни один путь не проходит через _confirmDanger по контракту §4.1/§7 ТЗ, поэтому оба зелёные без правок.

Это не «сторонний тест устарел сам по себе» — регресс создан ровно теми изменениями, которые в скоупе задачи (§4.1 ТЗ, миграция _deleteDraftWhole, _deleteMarker, _deleteSpace, _lockAction). Три из восьми уже адаптированных смоков показывают правильный образец правки — кликать по hp-confirm .danger-confirm-footer .btn... после await c.updateComplete (см. demo/smoke_lock_action.mjs, demo/smoke_lock_invariant.mjs, demo/smoke_saved_plans.mjs), тот же приём применим к оставшимся 5 файлам. До правки полный pre-release smoke-прогон (§8) зависнет на трёх из них.

Серьёзность: High. Блокирует; чинится в этой же задаче (§4.1 её собственный скоуп), issue не заводится.

L1 — шесть старых односложных ключей i18n остались без потребителей

confirm.delete_draft, confirm.delete_draft_segment, confirm.remove_marker, confirm.delete_space, confirm.unlock, confirm.delete_plan присутствуют во всех четырёх словарях, но после миграции ни один файл в src/**, test/**, demo/** их не использует — проверено:

$ grep -rn "confirm\.delete_draft'" src/ test/ demo/   # и так для всех шести — пусто

ТЗ §8 разрешает удаление старых ключей «только если source/test search доказывает отсутствие потребителей» — этот поиск и есть доказательство. AC-11 (парность словарей) не нарушен: возврат чист, находка не о корректности, а об оставленном мёртвом коде.

Серьёзность: Low. Не блокирует; можно поправить в этой же правке (удалить шесть ключей из всех четырёх словарей) либо оставить с запиской — на усмотрение автора.

Что проверено и совпало с кодом

  • AC-1/AC-2 — source-contract test (test/danger-confirmation.test.mjs, тест «all dangerous-action call sites use the shared confirmation contract») считает 0 вызовов confirm( и ровно 8 await this(.host)?._confirmDanger( в перечисленных трёх файлах, плюс проверяет все 6 диагностических key. Тест умеет падать: nativeCalls.length === 0 и sharedCalls.length === 8 оба жёстко зафиксированы, любое отклонение красит тест.
  • AC-3/AC-4/AC-5/AC-6/AC-7/AC-8/AC-9 — demo/smoke_danger_confirmation.mjs прогнан лично, зелёный; закрывает cancel-safety по всем 6 классам операций (включая оба lazy runtime для plan/space), replace-семантику, race (staleContextCannotMutate), фокус/трап/320px/локали.
  • AC-10 — demo/smoke_lazy_editor_chunk.mjs (не тронут диффом, прогнан лично) остаётся зелёным: onboardingDoesNotLoadEditor: true — controller и hp-confirm действительно в eager root, не тянут editor chunk.
  • AC-11 — npm test включает test/i18n.test.mjs, generic key-set parity (assert.deepEqual(Object.keys(dictionary).sort(), enKeys)) прошёл для всех четырёх словарей.
  • AC-12 — читением: _roomDeleteDialog, _partitionDeleteDialog, confirm.erase_decor, backup/optimize диалоги не тронуты диффом (нет их в списке изменённых файлов/функций), _editorSecondaryDialogBlocked получил добавление _dangerConfirm в общий OR-список блокировки secondary-панели — это тот же паттерн, что уже применён к _tapConfirm/_roomDialog и не меняет их собственную логику. smoke_editor_tabs, smoke_esc_dialogs (оба трогают _markerDialog/_roomDialog/_spaceDialog) зелёные.
  • AC-13 — см. таблицу гейтов выше, число совпадает с заявкой автора.
  • Race-safety §7 — читением всех 7 переписанных call sites (_deleteDraftWhole, _deleteDraftSegment, _deleteMarker, _deleteServerPlan×2, _deleteSpace×2): каждый захватывает identity (spaceId/draftId/segment/targetId/plan.url+modified) до await, затем перепроверяет её после — включая пересчёт collectSpaceMarkerDependencies и deletingLastSpace заново для _deleteSpace, ровно как требует §7 ТЗ. Дополнительно подтверждено исполнением: staleContextCannotMutate в smoke_danger_confirmation.mjs меняет _space между открытием и resolve и проверяет, что геометрия не мутировала.
  • Unlock revalidation — _lockAction (src/houseplan-card.ts:12766) перепроверяет _openingEntityAvailable, что _openingInfo — тот же proём/тип/lock id, и что состояние всё ещё 'locked', иначе не вызывает hass.callService. test/render-device-snapshot.test.mjs теперь проверяет тот же порядок статическим разбором продакшен-исходника.
  • Blocker-проверка раньше confirm (§4.1 таблица сценариев, «пространство») — в обеих реализациях _deleteSpace блокер-чек и ветка «последнее пространство» стоят строго до _confirmDanger, без изменений порядка относительно dev.
  • Bundle sync — сгенерированное дерево (dist/**, custom_components/houseplan/frontend/**, demo/srv/assets/**) идентично тому, что производит npm run bundle:sync на этом SHA (git status пустой после локальной сборки).
  • Трейлеры — все 7 коммитов имеют Issue: #32; User-Visible: yes стоит ровно у коммита c91ac74e, и оба CHANGELOG изменены в этом же коммите (git show --stat c91ac74e).
  • Документация — docs/ARCHITECTURE.md (новый абзац One modal contract), docs/USER-GUIDE.ru.md (терминология «Отмена», «Esc», диалог называет объект — согласовано с текущим текстом гайда, не изобретено).

Чего не проверял

  • 29 из 39 «прямых» и все 81 «слабых» строк smoke-select — обоснование в разделе «Как проверялось».
  • npm run golden:verify, python -m pytest tests_backend, performance-профили, npm run invariants — обоснование там же (не затронуты диффом/AC).
  • Ручное браузерное тестирование вне headless-смоков (touch-жесты на реальном устройстве, экранный ридер) — вне возможностей цикла; доверился demo/smoke_danger_confirmation.mjs и existing smoke_esc_dialogs/ smoke_dialog_footer_width как прокси для focus trap/320px/screen-reader разметки (role/aria наследуются от hp-dialog, не проверял вручную скринридером).
  • Полный прогон всех 209 демо-смоков — не требуется на этапе ревью (PROCESS.md §8, это пред-релизный гейт), но именно поэтому H1 не является «пропустил бы CI» — это ревью и должно было его поймать до пред-релизного гейта, что и произошло.

Вывод

AC-1…AC-13 подтверждены по коду и/или исполнением, кроме побочного эффекта миграции на пять не входящих в §4.1 файлов демо-смоков (H1) — тот же класс регресса, что и в описанном риске ТЗ «синхронный caller продолжит работу до решения», только на testing-инфраструктуре, а не в продакшен-пути. Продуктовый код и текстовый контракт корректны и полностью соответствуют ТЗ; блокирует только H1.

Вердикт: жёлтый. Возврат автору для правки 5 смоков (образец есть в этом же диффе) и по желанию — снятия L1.