Files
houseplan-card/docs/reviews/CODE-REVIEW-490-r1.md
T
2026-09-09 00:10:16 +03:00

21 KiB
Raw Blame History

CODE-REVIEW-490-r1

  • Issue: #490 — Атомарный recovery и live-состояние сводной панели
  • Этап: code (PROCESS.md §2.7)
  • Заход: r1 · блокирующих циклов израсходовано 0 из 4
  • SHA реализации: c6e6660d483c9297fc5fa86e9f9b5a7ad82a85a5 (ветка issue/490-summary-recovery-live-state)
  • ТЗ: docs/specs/490-summary-recovery-live-state.md, ревью ТЗ зелёное на 95a34f73 (см. комментарий issue)
  • Диапазон: git diff origin/dev...HEAD (57 файлов, 1179+/429-), из них продуктовый код: src/summary-panel.ts, src/summary-panel-runtime-loaded.ts, src/summary-panel-host.ts, src/houseplan-card.ts (2 строки), тесты test/summary-panel.test.mjs, test/render-device-snapshot.test.mjs, новый demo/smoke_summary_panel.mjs (recovery/live сценарии добавлены к существующему UI-смоку), scripts/smoke-links.mjs, changelog x2, dist/бандлы.

Скоуп

Задача устраняет F1 (lost-ACK recovery устанавливал только rev, но старый candidate) и F2 (entity-источники сводной панели не входили в render dependency set). Это единственная задача в этом заходе; ТЗ уже прошло отдельное зелёное ревью, здесь оценивается реализация против AC1–AC6.

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

Зелёного Validate на c6e6660d не нашёл (прогон не завершён/не найден), поэтому дешёвые гейты прогнал сам:

Гейт Результат
npx tsc --noEmit green, без ошибок
npm test green — 2276 pass, 0 fail, 1 skip (Node 22.23.2, Linux)
npm run build + сверка dist/custom_components/demo-srv green — рабочее дерево после build/node scripts/bundle-sync.mjs осталось чистым (без diff к закоммиченным трём копиям бандла)
node scripts/check-docs.mjs ERROR: «screenshot source fingerprint is stale ... (#479)» — это известный, помеченный самим инструментом долг #479 (отпечаток считается по всему src/**, любая фронтенд-правка делает его устаревшим); не регрессия этой задачи, отдельно не блокирую
node scripts/smoke-select.mjs --base origin/dev --head HEAD прогнал вывод, см. ниже
node demo/smoke_summary_panel.mjs green (OK) — включает новые recovery/live сценарии
node demo/smoke_render_invalidation.mjs green — прямое совпадение по entityIds
node demo/smoke_device_position_history.mjs green — прямое совпадение по _adoptStructuralResponses
node demo/smoke_danger_confirmation.mjs green — прямое совпадение по _adoptStructuralResponses
npm run invariants не запускал — diff не трогает рёбра/толщину/layout/marker.space/open_spans
python -m pytest tests_backend не запускал — custom_components/**/*.py не тронут (только фронтенд-бандл)
golden/performance полные наборы не запускал — это предрелизный гейт (PROCESS.md §8), diff не меняет геометрию/стили/слои рендера, только текстовые значения существующего блока

smoke-select дал 22 «прямых совпадения» и 58 «слабых связей» (общее имя _maybeRebuildDevices/_model/_cfgRev — решение ревьюера). Прогнал те прямые совпадения, что относятся к затронутым сиротным символам (entityIds, _adoptStructuralResponses); остальные прямые совпадения (_cfgContentFingerprint, _restoreZoom, SpaceModel и т.д.) относятся к путям, которые эта задача не меняла (сами функции идентичны на origin/dev, изменился только один новый вызывающий), и уже покрыты общим npm test/tsc.

Читал код (не исполнял отдельно) _adoptStructuralResponses, _beginContinuityCandidate, VisualContinuityController (src/visual-continuity.ts), ContentSigner.prepareImage/display (src/signing.ts), три существующих вызывающих места adoption-seam (_loadFromServer, _reloadConfigOnly, space-card.ts:718) — чтобы сравнить их с новым, четвёртым, в summary-panel-runtime-loaded.ts.

Находки

M1 — Recovery-адаптация обходит asset-readiness gate и continuity-overlay (в скоупе, Medium)

src/summary-panel-runtime-loaded.ts:439-452 (метод saveDialog(), ветка lost-ACK):

const authoritative = await this.host._getAuthoritativeConfig();
const confirmed = confirmedSummaryPanelWriteRecovery(authoritative, draft);
if (!confirmed) throw writeError;
const visibleSpace = this.host._space;
this.host._adoptStructuralResponses(authoritative);
void this.host._syncDecorAssets(confirmed.config).catch(() => undefined);
this.host._adoptInitialSpace(this.host._model, true);
this.host._cacheSnapshot();
if (this.host._space !== visibleSpace) this.host._restoreZoom();
this.host._regSignature = '';
this.host._maybeRebuildDevices();
recovered = true;

Это четвёртый вызывающий _adoptStructuralResponses в кодовой базе. Три существующих (houseplan-card.ts:4286 _loadFromServer, houseplan-card.ts:4467 _reloadConfigOnly, space-card.ts:718) все делают одно и то же перед вызовом adoption-seam:

if (configChanged && !await this._signer.prepareImage(
  this.hass, this._candidateBackdrop(candidateConfig),
)) {
  this._continuity.note('asset-failed');
  this._scheduleLoadRetry(true);
  return; // старый _serverCfg остаётся на экране, пока фон не готов
}

ContentSigner.prepareImage — «Sign and decode a protected image before a structural candidate adopts it. The current frame can therefore keep its old backdrop for the whole wait» (src/signing.ts:350-353). Новый путь в saveDialog() этот шаг не делает и не проверяет configChanged вовсе — _adoptStructuralResponses вызывается безусловно. Он также не вызывает _beginContinuityCandidate(...) (recovery-overlay, #73/#451) и не вызывает _resumePendingNavMode(), которые есть во всех трёх других местах.

Почему это задевает именно эту задачу. ТЗ §6.2 прямо требует: «Если authoritative adoption обнаруживает новую структурную базу, сохраняются действующие правила сброса stale history/drag/cache и continuity #73/#451. Recovery панели не получает собственного обходного варианта этих правил.» История/drag/cache сброс действительно сохранён — он находится внутри самого _adoptStructuralResponses (geometryHistory, devicePositionHistory, drag, camera — проверил чтением, это общий код, использованный без изменений). Но asset-readiness gate и recovery-overlay — тоже часть тех же continuity-правил (#73/#451, тот же файл visual-continuity.ts, та же авторская формулировка «keep its old backdrop for the whole wait») — и именно по ним recovery панели завела свой обходной вариант: единственный из четырёх вызывающих, который не ждёт prepareImage.

Сценарий отказа. Клиент A открывает диалог сводной панели, сохраняет. Ответ на config/set теряется по транспорту после того, как сервер его принял. Параллельно клиент B успевает сохранить несвязанную структурную правку — например, заменить фоновое изображение текущего пространства (spaces[i].bg.href) — именно такой параллельный несвязанный write и есть предмет AC1/AC2. Recovery клиента A подтверждает lost-ACK (confirmedSummaryPanelWriteRecovery проверяет только совпадение summary_panel, остальная часть документа — включая bg.href — принимается «как есть») и вызывает _adoptStructuralResponses(authoritative) напрямую. Новый bg.href ещё не подписан (ContentSigner.signed не содержит записи для нового пути) — _display() для непобранного URL возвращает '' («a signature we hold and still trust, else nothing», signing.ts:161-179), поэтому фон плана на один кадр/до ближайшего подписания пропадает (пустой src), вместо того чтобы держать старый фон, как это гарантируют три других пути. Само подписание триггерится тем же вызовом _display() и самовосстанавливается на следующем цикле — это не постоянная поломка, а видимый, но переходный дефект рендера именно в момент, который ТЗ описывает как основной (recovery при параллельной структурной правке).

Проверено чтением, не исполнением — воспроизвести смоком дорого (нужен реальный ContentSigner/подписанный URL в browser harness), а demo/smoke_summary_panel.mjs меняет только spaces[0].title и settings.concurrent_guard_490 — ни то, ни другое не задевает ContentSigner, поэтому текущий смок этот путь не покрывает и не может его поймать.

Серьёзность: Medium, в скоупе задачи (сам предмет AC1/AC2 — атомарное принятие «всего» документа при recovery, включая структурные поля вроде фона). Не блокирует (не High): не ломает данные и не искажает summary_panel, эффект переходный и самовосстанавливающийся, требует узкого стечения (lost ACK + именно в этом окне конкурентная правка фона). Чинится добавлением configChanged-гейта на prepareImage (и, по аналогии с тремя другими местами, _beginContinuityCandidate) перед вызовом _adoptStructuralResponses в этой ветке — без выдумывания нового поведения, тем же способом, что и в трёх существующих вызывающих.

Что проверено и корректно

  • AC1/AC2 (атомарность и следующая запись). confirmedSummaryPanelWriteRecovery (src/summary-panel.ts) требует одновременно: полный ServerConfig (Array.isArray(config.spaces)), числовой неотрицательный safe-integer rev, точное совпадение сохранённой summary_panel с draft (sameSummaryPanel, JSON-эквивалентность). При выполнении — весь authoritative документ и rev уходят через общий _adoptStructuralResponses, а не собираются вручную — это закрывает риск «принят только rev или только config» из ТЗ §11. Подтверждено и unit-тестом (test/summary-panel.test.mjs, матрица из 7 отрицательных и 1 положительного случая) и browser-смоком demo/smoke_summary_panel.mjs (recoveryAdoptsWholeConfig, recoveryAdoptsRevision, nextWriteUsesRecoveredRevision, nextWritePreservesConcurrentChange) — прогнал сам, green. Тест умеет падать: ассерты сверяют конкретные строки ('Concurrent title kept', 'kept') и конкретную revision-арифметику (writes[0].expected_rev + 2), а не факт отсутствия исключения.
  • AC3 (настоящий конфликт). Ветка catch { throw writeError; } вокруг recovery-попытки — любой сбой самого чтения (_getAuthoritativeConfig бросает) или отказ confirmedSummaryPanelWriteRecovery (панель отличается / config или rev невалидны) откатывается к исходной ошибке записи, диалог не закрывается. Смок trueConflictStaysOpen (принудительный rejectConflict = true на каждой попытке) — green, и явно проверяет, что предыдущая («Second summary write») сохранённая версия осталась в _serverCfg, а не смешалась со stale candidate.
  • AC4/AC5 (live-источники и fast path). summaryPanelEntityIds() — чистая функция, не читает hass, не мутирует конфиг, схлопывает дубликаты, режется по runnableSummaryPanel (только поддерживаемая v1), игнорирует локальную видимость/scope, что соответствует ТЗ §7.1 дословно («число ограничено схемой» — до 200 значений по SUMMARY_PANEL_SCHEMA, что и проверяет unit-тест на hidden/other-scope/system/unsupported случаях). В houseplan-card.ts:4623 результат добавлен в тот же entityIds render-snapshot набор, что и устройства/комнаты/декор — второго listener/timer не создано (проверено чтением всего файла на предмет других мест, где могла бы завестись отдельная подписка). Live-переходы value → unavailable → recovered → missing → present и счётчики рендера/geometry-инвариантность проверены смоком (liveState секция) — green, включая unrelatedTickSkipped: true, relevantTickRenderedOnce: true, stateTicksKeepGeometry: true.
  • Кеш-инвалидация снапшота. _capturedSnapshotSequence = -1 добавлен именно в момент ленивой загрузки LoadedSummaryPanelRuntime (connectedCallback, houseplan-card.ts:2613) — без него уже захваченный снапшот (_hassSequence не поменялся) не подхватил бы вновь появившиеся entityIds() до следующего изменения hass. В самом recovery-пути отдельный сброс не нужен: _adoptStructuralResponses переприсваивает _serverCfg новой ссылкой при structural-change, Lit-реактивность ловит это в willUpdate (houseplan-card.ts:4084-4088) и инкрементирует _cfgEpoch, что и инвалидирует кеш снапшота обычным путём — проверено чтением.
  • AC6 (совместимость). Диф не трогает EN/RU/DE/FR словари, не добавляет новых сетевых запросов в steady state (единственный новый config/get — тот же самый вызов, что и раньше, просто с другим следствием), не меняет схему/API. test/render-device-snapshot.test.mjs подтверждает, что _summary?.entityIds() вошёл именно в существующую сборку снапшота, а не завёл параллельную.
  • Трейлеры и changelog. Issue: #490 на всех трёх коммитах, User-Visible: yes на коммите реализации c6e6660d, и docs/CHANGELOG.md+docs/CHANGELOG.ru.md правлены в этом же коммите (не отдельным) — соответствует требованию.
  • Единственный источник числа. Значение summary-only сущности показывается только в одной строке сводной панели; диф не вводит второе место, где то же значение показывалось бы снова (никакой другой слой плана не подписан на источники, которых нет в общем entityIds) — не вижу дублирующего числа, которое требовало бы отдельного разбора по «одно число — один источник».

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

  • Полный golden:verify — diff не меняет геометрию/раскладку/стили, только текстовые значения существующего блока и внутреннюю логику recovery; посчитал непропорциональным для этой правки.
  • Windows-специфичные 3 падения npm test, о которых пишет автор (#479) — не переисполнял на Windows, полагаюсь на то, что Linux-прогон (канонический CI job) у меня самого дал 0 fail.
  • npm run invariants — diff не трогает геометрию/рёбра/layout/marker.space/open_spans.
  • python -m pytest tests_backend — custom_components/**/*.py не в diff (только сгенерированный фронтенд-бандл под custom_components/houseplan/frontend/).
  • Полную матрицу demo/smoke_*.mjs (232 файла) — прогнал только прямые совпадения, относящиеся к реально изменённым символам (entityIds, _adoptStructuralResponses); остальные 20 «прямых» и все «слабые» совпадения относятся к функциям, чьи тела не менялись в этом diff (только один новый вызывающий), и уже покрыты tsc+npm test+build.
  • Живое ручное тестирование в браузере HA — недоступно в этом цикле; вывод построен на автотестах и чтении кода.

Вердикт

Жёлтый: единственная находка (M1) — Medium в скоупе задачи, чинится в этой же ветке без отдельного issue (решение владельца 2026-08-19, #202). AC1–AC6 в остальном подтверждены тестами, которые умеют падать, и чтением кода там, где смок не достаёт (сам M1).


Материал раунда

  • Ветка: issue/490-summary-recovery-live-state, коммит c6e6660d483c — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: fc7364e6d21b43b3f08c757a8ce0d5d5ac9b48c2
    git log --all --format='%H %T' | grep fc7364e6d21b
    
  • ТЗ docs/specs/490-summary-recovery-live-state.md, блоб d4bfd4ea63f1b2ce1cb255b2764fe14d25661df7
    git log --all --find-object=d4bfd4ea63f1b2ce1cb255b2764fe14d25661df7 -- docs/specs/490-summary-recovery-live-state.md