# 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:` раньше `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» выше. --- ## Материал раунда - Ветка: `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