Files
houseplan-card/docs/reviews/CODE-REVIEW-490-r2.md
T
2026-09-08 21:20:14 +00:00

205 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-490-r2
- **Issue:** #490 — Атомарный recovery и live-состояние сводной панели
- **Этап:** code (PROCESS.md §2.7)
- **Заход:** r2 · блокирующих циклов израсходовано 1 из 4
- **SHA материала:** `01c653a4c660e47d62c1f4bcb879b634ea09800d` (рабочая копия)
- **ТЗ:** `docs/specs/490-summary-recovery-live-state.md`, ревью ТЗ зелёное на `95a34f73`
- **Диапазон:** `git diff origin/dev...HEAD` (58 файлов), продуктовый код:
`src/summary-panel-runtime-loaded.ts`, `src/summary-panel-host.ts`, `src/summary-panel.ts`,
`src/houseplan-card.ts` (2 строки), `demo/smoke_summary_panel.mjs`, `scripts/smoke-links.mjs`,
тесты `test/summary-panel.test.mjs`, `test/render-device-snapshot.test.mjs`, changelog×2,
dist/бандлы.
## Почему разбор полный, а не по дельте r1→r2
Автор перебазировал ветку на ушедший вперёд `dev` (`origin/dev` = `24c1b723`, инфраструктурный
коммит про review-controller/доки, продуктового кода не касается). SHA реализации r1
(`c6e6660d483c9297fc5fa86e9f9b5a7ad82a85a5`), названный в вердикте r1 и в самом документе
`docs/reviews/CODE-REVIEW-490-r1.md` («ребейз его осиротит, и это нормально»), в этом дереве
не резолвится (`git cat-file` → `Not a valid commit name`). Это прямо описанный в инструкции
случай «ребейз на ушедший вперёд dev — после ребейза это другой код» (§7.2), поэтому ниже —
полный разбор AC1–AC6, а не только чтение диффа фикса M1. По факту `dev` продвинулся только
инфраструктурно (`.github/workflows/process.yml`, `AGENTS.md`, `PROCESS.md`, `docs/SCOPE.md`,
`docs/STATUS.md`, `docs/TESTING.md`) — конфликтов с продуктовым кодом #490 нет, поэтому большая
часть выводов совпадает с r1, но получена самостоятельным повторным прогоном, а не унаследована
на слово.
## Скоуп
Задача устраняет F1 (lost-ACK recovery принимал только `rev`, но старый `candidate`) и F2
(entity-источники сводной панели не входили в render dependency set). r1 нашёл единственную
находку M1 (Medium, в скоупе): recovery-адаптация вызывала общий `_adoptStructuralResponses`
напрямую, минуя asset-readiness gate (`_signer.prepareImage`) и continuity-overlay
(`_beginContinuityCandidate`), которые есть во всех остальных вызывающих того же seam. Эта
ревизия проверяет, закрыто ли M1, и заново проверяет AC1–AC6 целиком.
## Как проверялось
Зелёного Validate на `01c653a4` нет, поэтому дешёвые гейты прогнал сам:
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | green, без ошибок |
| `npm test` | green — 2282 pass, 0 fail, 1 skip (Node 22.23.2, Linux) |
| `npm run build` + `node scripts/bundle-sync.mjs` | green; `git status --porcelain` пуст — три копии бандла (`dist/`, `custom_components/houseplan/frontend`, `demo/srv/assets`) совпадают с закоммиченными |
| `node scripts/check-docs.mjs` | ERROR «screenshot source fingerprint is stale... (#479)» — тот же известный, помеченный самим инструментом долг, что и в r1; не регрессия этой задачи (отпечаток считается по всему `src/**`, любая фронтенд-правка делает его устаревшим) |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 25 «прямых совпадений», 58 «слабых связей»; вывод приложен ниже |
| `node demo/smoke_summary_panel.mjs` | green — включает новый сценарий `recoveryPreparesBackdropBeforeAdoption` |
| `node demo/smoke_render_invalidation.mjs`, `smoke_device_position_history.mjs`, `smoke_danger_confirmation.mjs` | green (прямые совпадения по `entityIds`/`_adoptStructuralResponses`, уже прогонялись в r1, перепрогнаны) |
| `node demo/smoke_space_card_bg.mjs`, `smoke_sign_cap.mjs` | green — новые прямые совпадения по `_signer`, символ именно из фикса M1, в r1 не прогонялись |
| `node demo/smoke_version_recovery.mjs` | green — прямое совпадение по `_signer`/`_cfgContentFingerprint`/`_cfgRev`, концептуально ближайший к теме recovery смок |
| `node demo/smoke_nav_persist.mjs` | green — прямое совпадение по `_resumePendingNavMode`, который фикс M1 добавил в recovery-ветку |
| `node demo/smoke_linked_virtual_light.mjs` | green — прямое совпадение по `_continuity` |
| `npm run invariants` | не запускал — diff не трогает рёбра/толщину/`layout`/`marker.space`/`open_spans` |
| `python -m pytest tests_backend` | не запускал — `custom_components/**/*.py` не в diff (только сгенерированный фронтенд-бандл) |
| `npm run golden:verify`, performance-профили | не запускал — diff не меняет геометрию/раскладку/стили, только логику recovery и текстовые значения существующего блока; это предрелизный гейт (PROCESS.md §8) |
`smoke-select` (42 символа на изменённых строках, порог «широкого» — 46 смоков, матрица 232):
из 25 прямых совпадений прогнал те, что относятся к реально изменённым символам этого раунда
(`_signer.prepareImage`-гейт, `_continuity`, `_beginContinuityCandidate`, `_resumePendingNavMode`,
`entityIds`, `_adoptStructuralResponses`) — 9 смоков выше. Остальные 16 прямых совпадений
(`_cfgRev`, `_model`, `_maybeRebuildDevices`, `_cfgContentFingerprint`, `SpaceModel` и т.д.)
относятся к функциям, чьи тела в этом диффе не менялись (только новый вызывающий в
`saveDialog()`), уже покрыты `tsc`+`npm test`+`build` и были обоснованно пропущены ещё в r1 —
причина не изменилась. 58 «слабых связей» (одно распространённое имя) не прогонял.
### Проверка дисциплины «тест умеет падать» для нового ассерта
Смок `demo/smoke_summary_panel.mjs` теперь мокает `card._signer.prepareImage` и
`card._adoptStructuralResponses`, чтобы записать порядок вызовов при конкурентной замене
`spaces[0].plan_url`, и проверяет `recoveryPreparesBackdropBeforeAdoption`. Чтобы убедиться, что
это не тавтология, временно откатил гейт (убрал вызов `prepareImage`/условие `configChanged`
перед `_adoptStructuralResponses` в `saveDialog()`), пересобрал (`npm run build` +
`bundle-sync`) и перезапустил смок:
```
FAILED (1):
- recoveryPreparesBackdropBeforeAdoption: expected true, got false
```
Затем восстановил файл из бэкапа, пересобрал и подтвердил чистое дерево (`git status
--porcelain` пуст) и зелёный смок. Тест умеет падать именно на том поле, которое доказывает M1.
## Закрытие раунда r1
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M1** (Medium, в скоупе) — recovery-adoption вызывал `_adoptStructuralResponses` напрямую, минуя `_signer.prepareImage()`-гейт и `_beginContinuityCandidate()`; конкурентная правка `bg.href`/`plan_url` могла на кадр показать пустой фон вместо удержания старого | `src/summary-panel-runtime-loaded.ts:443-455`: перед `_adoptStructuralResponses` теперь вычисляется `configChanged` (тот же `contentFingerprint`-компаратор, что в `_loadFromServer`/`_reloadConfigOnly`), при изменении конфига вызывается `this.host._signer.prepareImage(...)`, при неготовом asset — `_continuity.note('asset-failed')` + `_scheduleLoadRetry(true)` + `throw writeError` (adoption не выполняется), при готовом — условный `_beginContinuityCandidate('summary-recovery', true)` и только затем `_adoptStructuralResponses`; следом добавлен `_resumePendingNavMode()`, отсутствовавший в r1 | Строки кода выше; `demo/smoke_summary_panel.mjs` — сценарий `recovery` подменяет `plan_url` на `concurrentBackdrop` при синтетическом lost-ACK и мокает `prepareImage`/`_adoptStructuralResponses`, чтобы записать порядок вызовов (`recoveryOrder`); ассерт `recoveryPreparesBackdropBeforeAdoption` требует `prepare:<href>` раньше `adopt`. Проверено самостоятельным прогоном (green) и подтверждено способностью падать (см. выше) |
Соответствие порядку операций трём существующим вызывающим (`_loadFromServer`,
`_reloadConfigOnly`) проверено построчным сравнением (см. `src/houseplan-card.ts:4296-4325` и
`:4486-4506`): та же последовательность `configChanged → prepareImage-гейт →
[beginContinuityCandidate] → adoptStructuralResponses → syncDecorAssets → adoptInitialSpace →
resumePendingNavMode → cacheSnapshot → restoreZoom → regSignature reset →
maybeRebuildDevices`. Новый путь recovery воспроизводит этот же порядок вместо собственного
обходного варианта, что и требовало ТЗ §6.2.
Единственное отличие от `_loadFromServer`: там `_regSignature`/`_maybeRebuildDevices()`
выполняются в `finally` (в том числе при раннем `return` на asset-failed), а в recovery-ветке —
только на успешном пути (`recovered = true`), потому что asset-failed здесь пробрасывает
исходную `writeError` наружу как обычную ошибку записи (диалог остаётся открытым с ошибкой, а
не молча продолжает). Фоновый `_scheduleLoadRetry(true)` в этом случае в итоге доведёт adoption
до конца через штатный `_loadFromServer`/`_reloadConfigOnly`, который сам сделает эти сбросы.
Не нахожу здесь нового дефекта — это не обход правил continuity, а откладывание adoption до
готовности, ровно как требует ТЗ.
## Унаследовано из r1
Поскольку ребейз формально осиротил материал r1, я не унаследовал выводы «на слово» — весь
код и все AC перепроверены заново в этом раунде (таблица гейтов и находка M1 выше — результат
самостоятельного прогона на `01c653a4`, а не копия r1). Без повторной проверки принято только
продуктовое/процессное, что дельта не затрагивает и не может затронуть технически:
- Оценка S2 (пользовательская/девелоперская ценность 9/10, риск 8/10, приоритет P1, полный
трек, дубликаты/связи с #437 и #493) и зелёное ревью ТЗ на `95a34f73` — issue-комментарии,
не код; дельта реализации их не задевает.
- Формулировка AC1–AC6 и границы «Не входит» из ТЗ §5 — сам текст ТЗ не менялся в этом раунде.
Документ r1: `docs/reviews/CODE-REVIEW-490-r1.md` (в дереве, коммит `87f19941`).
## Что проверено и корректно (AC1–AC6, полный повторный разбор)
- **AC1/AC2 (атомарный lost-ACK recovery и следующая запись).**
`confirmedSummaryPanelWriteRecovery` (`src/summary-panel.ts`) требует одновременно полный
`ServerConfig` (`Array.isArray(config.spaces)`), safe-integer неотрицательный `rev`, точное
совпадение сохранённой `summary_panel` с draft (`sameSummaryPanel`). При выполнении — весь
`authoritative` документ и `rev` уходят через общий `_adoptStructuralResponses`, ничего не
собирается вручную. Юнит (`test/summary-panel.test.mjs`, 1 позитивный + 7 негативных случаев)
и смок (`recoveryAdoptsWholeConfig`, `recoveryAdoptsRevision`, `nextWriteUsesRecoveredRevision`,
`nextWritePreservesConcurrentChange`) — прогнаны самостоятельно, green. Ассерты сверяют
конкретные строки (`'Concurrent title kept'`) и revision-арифметику (`writes[0].expected_rev +
2`), а не факт отсутствия исключения.
- **AC3 (настоящий конфликт).** Ветка `catch { throw writeError; }` — любой сбой чтения или
отказ `confirmedSummaryPanelWriteRecovery` откатывается к исходной ошибке, диалог не
закрывается. Смок `trueConflictStaysOpen` (принудительный `rejectConflict`) — green,
подтверждает, что предыдущая сохранённая версия осталась в `_serverCfg`.
- **AC4/AC5 (live-источники и fast path).** `summaryPanelEntityIds()` — чистая функция без
чтения `hass`/мутации конфига, схлопывает дубликаты, режется по `runnableSummaryPanel` (только
v1), игнорирует локальную видимость/scope (ТЗ §7.1 дословно). В `houseplan-card.ts:4623`
результат добавлен в тот же `entityIds` render-snapshot набор построчным `for`, второго
listener/timer не создано (перечитал файл на предмет альтернативных подписок — не нашёл).
Live-переходы value → unavailable → recovered → missing → present и инвариантность
geometry/epoch/layoutRev проверены смоком (`liveState`) — green, включая
`unrelatedTickSkipped: true`, `relevantTickRenderedOnce: true`, `stateTicksKeepGeometry: true`.
`_capturedSnapshotSequence = -1` в `connectedCallback` (`houseplan-card.ts:2613`) инвалидирует
кеш снапшота именно в момент ленивой загрузки рантайма — без этого свежепоявившиеся
`entityIds()` не попали бы в snapshot до следующего изменения `hass`; проверено чтением.
- **AC6 (совместимость).** Диф не трогает EN/RU/DE/FR словари, не меняет схему/API, не
добавляет сетевых запросов в steady state (`config/get` — тот же вызов, что и раньше, с другим
следствием). `test/render-device-snapshot.test.mjs` подтверждает, что `_summary?.entityIds()`
вошёл именно в существующую сборку снапшота.
- **Трейлеры и changelog.** `Issue: #490` на всех пяти коммитах диапазона; `User-Visible: yes`
на обоих fix-коммитах (`a43bc3eb`, `01c653a4`); `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md`
правлены в тех же коммитах, а не отдельно — проверено `git show --stat` на каждом.
Формулировка changelog-записи в `01c653a4` («while keeping the current plan backdrop visible
until a concurrent replacement is ready» / «текущая подложка остаётся видимой, пока
параллельно заменённая подложка не готова») точно описывает закрытие M1, а не только
исходные F1/F2.
- **Единственный источник числа.** Значение summary-only сущности показывается только в одной
строке сводной панели; диф не вводит второе место того же значения. `test/single-source-
numbers.test.mjs` прошёл в общем `npm test`.
## Что не проверял
- Полный `npm run golden:verify` и performance-профили — diff не меняет геометрию/раскладку/
стили, только логику recovery и текстовые значения существующего блока; предрелизный гейт
(PROCESS.md §8), непропорционален этой правке.
- `npm run invariants` — diff не трогает рёбра/толщину/`layout`/`marker.space`/`open_spans`.
- `python -m pytest tests_backend` — `custom_components/**/*.py` не в diff.
- Полную матрицу `demo/smoke_*.mjs` (232 файла) — прогнал 9 прямых совпадений на реально
изменённых символах этого раунда (`_signer`, `_continuity`, `_resumePendingNavMode`,
`entityIds`, `_adoptStructuralResponses`) плюс сам целевой `smoke_summary_panel.mjs`; остальные
16 прямых и все 58 «слабых» совпадений относятся к функциям, чьи тела не менялись (только
новый вызывающий), и уже покрыты `tsc`+`npm test`+`build`.
- Живое ручное тестирование в браузере HA — недоступно в этом цикле; вывод построен на
автотестах (включая целевой откат-и-проверку смока выше) и чтении кода.
- Behaviour ветки `configChanged && !prepareImage` (asset-failed) для самого recovery-пути не
воспроизведён смоком отдельно (только через ручной откат гейта выше, который доказывает
обратное — что гейт есть и работает); полноценный «asset действительно не готов» сценарий для
recovery повторяет уже покрытый тремя другими вызывающими путь и не является новым риском.
## Вердикт
Зелёный: M1 закрыт способом, который ТЗ §6.2 и требовал (тот же общий гейт/порядок, что у трёх
остальных вызывающих `_adoptStructuralResponses`, без собственного обходного варианта). Новых
находок нет. AC1–AC6 подтверждены самостоятельным повторным прогоном тестов (юнит + смок), а не
унаследованы из r1 на слово — обоснование в разделе «Унаследовано из r1» выше.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/490-summary-recovery-live-state`, коммит `01c653a4c660` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3cf0250fc745fd6fe94f7f344f2aa3beb5ddd682`
```
git log --all --format='%H %T' | grep 3cf0250fc745
```
- ТЗ `docs/specs/490-summary-recovery-live-state.md`, блоб `d4bfd4ea63f1b2ce1cb255b2764fe14d25661df7`
```
git log --all --find-object=d4bfd4ea63f1b2ce1cb255b2764fe14d25661df7 -- docs/specs/490-summary-recovery-live-state.md
```
- Вердикт конвейера: `green` · High 0