# CODE-REVIEW-437-r2 Issue: [#437](https://github.com/Matysh/houseplan-card/issues/437) — конфигурируемая read-only сводная панель поверх плана. Этап: код-ревью, заход **r2**, блокирующих циклов израсходовано **1/4** до этого раунда (полный трек, лимит 4). Материал этого раунда: `git diff 96e07b9a..89976c85` (`origin/dev..HEAD`, HEAD = `89976c85edc443da6a08ca0034eaa99d322f13d0`). ## Материал раунда r1 и объявление дельты (PROCESS §2.10) - Вердикт r1: жёлтый, High 0 / Medium 4, документ `docs/reviews/CODE-REVIEW-437-r1.md`. - Материал r1 назван в самом документе r1: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD` на SHA `96e07b9a0255eef3a55cd6c3be08198a0adcc7ba`. SHA резолвится (не мёртвый), проверено `git show 96e07b9a --stat` — коммит существует в истории ветки. - Дельта r1→r2: `git diff 96e07b9a..HEAD` — 4 коммита (`3c1a9f8f`, `af441149`, `336d8f30`, `89976c85`), 60 файлов в diffstat, из которых источник (`src/**`, `test/**`, `demo/**`, `docs/**`) — 11 файлов; остальное — сгенерированное (класс D, три копии бандла) и служебное (`tsconfig.test.json`). - **Разбор полным не признан необходимым**: дельта не ребейзится на ушедший вперёд `dev` (родитель `HEAD` для `dev` — тот же `ed9ee026`, что и у материала r1), контракт AC1–AC26 не меняется, новая подсистема в буквальном смысле не заведена (`boot-soft-layout.ts` — извлечение уже существовавшей в `houseplan-card.ts` логики измерения шапки в отдельный файл, не новая возможность). Объём дельты (11 файлов источника, +366/−125 без сгенерированного) не сопоставим с объёмом исходной задачи (82 файла, +5131/−424). Разбор велся **по дельте** с одним отступлением: раздел «Проверено дополнительно» ниже — камера/refit-путь тронут дельтой напрямую (`_prepareCameraCommand`, `_refitView`, `_stagePointerDown`, `_bootSoftCancel`), поэтому прогнан более широкий, чем формальный diff, набор смоков именно по этому пути (см. «Как проверялось»), а не только `demo/smoke_summary_panel.mjs`. ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | **M1** — `demo/smoke_kiosk.mjs` падал (`TypeError: reading 'icon' of null`), т.к. `_saveKioskScale` больше не пишет в легаси-ключ `houseplan_card_kiosk_v1` | Смок переписан: сеет легаси-ключ заранее, читает новый per-instance ключ `houseplan.summary-panel.v1:*` и отдельно проверяет, что легаси-ключ не тронут | `demo/smoke_kiosk.mjs` (коммит `3c1a9f8f`, diff +14/−1); прогнан лично: `node demo/smoke_kiosk.mjs` → `OK`, `"persisted": true`, `"legacyScaleUntouched": true` | | **M2** — защита «удалённое устройство не увеличивает счётчик» (Q2/AC18) без теста-свидетеля | Фикстура `test/summary-panel.test.mjs` расширена `removed`-маркерами (`d4` — удалённое устройство в области, `d5` — удалённый родитель с восстановленной дочерней сущностью); ожидаемый результат `['d1','d2','d5']` требует именно этой пары гардов | `test/summary-panel.test.mjs:169-203`; **воспроизведено лично** — см. таблицу «чем краснеет» ниже, тест реально падает при снятии гарда | | **M3** — tap-target 34×34 CSS px против контракта ТЗ §5.4 (≥44×44); попутно найдено расхождение `TOUCH-SUPPORT.md` («панель + составной контрол» уже сузило формулировку) | `.summary-editor-row button`, `.summary-drag`, `.summary-switch`, `select`, `input[type=range]`, кнопки footer — все подняты до `min-width/height: 44px`; `TOUCH-SUPPORT.md` расширен на «и его простая форма настроек» | `src/summary-panel-style.ts` (диф `96e07b9a..3c1a9f8f`); `docs/TOUCH-SUPPORT.md` (диф `96e07b9a..HEAD`); witness `test/summary-panel.test.mjs` regex на `min-width:44px;...height:44px` — прогнан, зелёный | | **M4** — AC21 (устойчивая идентичность карточки в Masonry) без теста-свидетеля, резолвер использовал «сырой» DOM-child-index на каждом уровне | Новый `src/summary-panel-identity.ts`: структурный путь до ближайшего `hui-card` вычисляется один раз и кешируется в `WeakMap` на сам DOM-узел обёртки — последующий реордер колонок Masonry возвращает старое значение из кеша, а не пересчитывает | `src/summary-panel-identity.ts` (новый файл); `test/summary-panel.test.mjs:145-167` («placement identity survives Masonry reflow and inner-card remount»), прогнан, зелёный. **Остаточное наблюдение, не блокирует** — см. Low L3 ниже | Все четыре находки r1 закрыты по существу, с независимым воспроизведением, не только заявлением автора. ## Унаследовано из r1 (без повторной проверки) Ниже — то, что дельта `96e07b9a..HEAD` не затрагивает, поэтому наследуется из `docs/reviews/CODE-REVIEW-437-r1.md` (материал SHA `96e07b9a`) без повторной проверки в этом раунде: - структура диффа и границы SCOPE (backend `settings.summary_panel`, версионирование, change-aware проверка ссылок) — `custom_components/houseplan/validation.py`, `const.py`, `websocket_api.py` (кроме однострочной правки импорта в `af441149`, проверенной отдельно ниже); - AC1, AC3–AC14, AC16, AC19, AC20, AC22–AC26 — доказательства этих AC лежат в файлах, не тронутых дельтой (`src/summary-panel-editor.ts`, `summary-panel-host.ts`, дизайн диалога, лимиты, i18n-словари, backend-хранение, kiosk-права кроме локального ключа); - `pytest tests_backend/test_summary_panel.py` — 9 passed на `96e07b9a` (полный HA-harness недоступен и в этом окружении — см. «Чего не проверял»); дельта трогает backend только порядком импорта (`af441149`), не логику; - `npm run golden:verify` полный прогон и `check-docs --screenshots` эталоны — не перезапускались, т.к. предрелizный гейт, не гейт ревью (PROCESS §8), а видимый результат панели дельтой не менялся (менялись только touch-размеры формы настроек и внутренняя логика identity/camera, не геометрия/цвета/раскладка панели). ## Как проверялось — гейты Зелёного Validate на `89976c85` не найдено — прогнал сам. | Гейт | Команда | Результат | |---|---|---| | Типы | `npx tsc --noEmit` | pass, 0 ошибок | | Юниты (полный набор) | `npm test` | 2268 passed, 0 failed, 1 skipped (34.7s) | | Сборка + бандл (3 копии) | `npm run build && npm run bundle:sync` | pass; `cmp` подтвердил байтовое совпадение `dist/houseplan-card.js` ↔ `custom_components/.../houseplan-card.js` ↔ `demo/srv/assets/houseplan-card.js` | | Новый `any` | `node scripts/no-new-any.mjs --base 96e07b9a --head HEAD` | pass, 164 добавленные строки в 5 файлах, новых `any` нет | | Документация (диф трогает `src/**`) | `node scripts/check-docs.mjs` | красный ожидаемо: отпечаток скриншотов устарел (правило #479, некритично на обычном push, обязательно перед beta candidate) | | Бюджет бандла | `node scripts/bundle-budget.mjs` | pass, initial View 291 481 B при потолке 292 000±2000; уже учтённое предупреждение о запасе (#367), не новая находка | | Процесс-гейт | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (10 коммитов в диапазоне `origin/dev..HEAD`) | | Выбор смоков по дельте r1→r2 | `node scripts/smoke-select.mjs --base 96e07b9a --head HEAD` | 43 прямых совпадения (камера/`_view`/`_zoom`/`_applyView` — широкая зона, ожидаемо для правки refit-пути) + 43 слабые связи | | Целевые смоки (M1–M4 + прямая зона дельты) | `node demo/smoke_kiosk.mjs`, `smoke_summary_panel.mjs`, `smoke_houseplan_panel.mjs`, `smoke_danger_confirm_branches.mjs`, `smoke_room_fit.mjs` | все **OK** | | Камера/refit смоки (дельта правит `_refitView`/`_applyView`/`_bootSoftCancel`) | `node demo/smoke_smooth_zoom.mjs`, `smoke_pan_any_zoom.mjs`, `smoke_zoom_out.mjs` | первые два **OK**; **`smoke_zoom_out.mjs` красный** — см. Medium M5 | | Мутационная проверка защиты AC18 (M2) | ручная правка `test-build/summary-panel-metrics.js`: снял `removed.devices.has(device.id)` из первого цикла, вернул после проверки | тест **упал** (см. таблицу ниже), затем восстановлен, `git status` чист | | Backend | `python -m pytest tests_backend -q` | **не прогонялся** — в этом окружении нет `.venv-backend` и системного HA; дельта трогает backend только порядком импорта (`af441149`), риск логики нулевой, наследуется прогон r1 (396 passed, 3 skipped) | | Golden/invariants/perf | — | не прогонялись: дельта не меняет геометрию, `layout`, ссылки на пространства/толщины, видимый рендер панели или производительность — прогонять не по чему (PROCESS §8, «по необходимости») | ### Защитный AC — таблица «чем краснеет» (PROCESS §2.7, только для новых/переоткрытых в этом раунде) | AC | Чем доказан | Чем краснеет | |---|---|---| | AC18 (устройства не дублируются, удалённые/восстановленные считаются верно) — M2 | `test/summary-panel.test.mjs` тест «device total counts unique…» | Снял `removed.devices.has(device.id)` из первого цикла `representedHaDeviceIds` → тест упал: ожидалось `['d1','d2','d5']`, получено `['d1','d2','d4','d5']` (утечка удалённого `d4`). Воспроизведено лично, файл восстановлен. | ## Находки ### Medium (в скоупе задачи, чинится в этой же задаче) **M5 — регресс восстановления камеры View при возврате из редактора, унаследован из r1-материала и не закрыт дельтой.** `demo/smoke_zoom_out.mjs` на `HEAD` (`89976c85`) красный: ``` FAILED (1): - viewCenterRestored: expected true, got false ``` Сценарий смока (не новый, существовал до #437): View zoom 1.6 со смещённым центром → `devices`-редактор zoom 2.5 → назад в View. Ожидание — та же точка центра, что была до входа в редактор (`docs/houseplan-card.ts` явно документирует это как контракт: «the pre-editor viewport (zoom AND center) comes back»). Численно (инструментированный прогон, X/Y координаты логической viewBox): - ожидалось `c = [-890.839…, -532.149…]`; - получено `c = [-806.25, -532.149…]` — Y точный, X расходится на **84.59** единиц. **Это не регресс, внесённый коммитом `89976c85`.** Проверено прямым запуском того же инструментированного смока на материале r1 (`git worktree add` на SHA `96e07b9a`, `npm run bundle:sync`, `node demo/smoke_zoom_out_debug.mjs`) — идентичный результат, `_debug_dist: 84.58904109589048`, до последнего знака. На `origin/dev` (`ed9ee026`, до фичи #437) тот же смок — **зелёный**. То есть регресс внесён где-то в самом фиче-коммите `96e07b9a` («feat: add configurable summary panel»), пережил ревью r1 (в списке гейтов r1 `smoke_zoom_out.mjs` не значится — не был выбран/запущен) и не тронут дельтой r1→r2. Механизм (прочитан, не отлаживался пошагово по всем ветвям): `houseplan-editor-runtime.ts:1283` вызывает `this.host._bootSoftCancel()` безусловно в начале `_setMode()` — при **каждом** переключении режима, а не только по пользовательскому вводу на плане. `_bootSoftCancel()` (новый механизм #437, `src/boot-soft-layout.ts` + `houseplan-card.ts:6559-6568`) в первые `BOOT_SOFT_MS=1500ms` после раскрытия карточки синхронно замеряет высоту шапки и вызывает `_applyView(this._zoom, cx, cy)` от **текущего** `this._view`. Тест переключает режимы сразу после запуска демо-страницы (внутри окна `_bootSoft`), поэтому этот пересчёт вклинивается до того, как `_setMode` фиксирует `_viewModeSnap`/восстанавливает `targetCenterX/Y` из него, и итоговый X смещается. Это существующий, задокументированный в коде контракт продукта («редактор — рабочий инструмент, не то, что пользователь хочет видеть после»), а не пункт AC1–AC26 из ТЗ #437 буквально — но поломан кодом, который #437 сам добавил (`_bootSoftCancel`, вызываемый из каждого `_setMode`), и относится к разряду «изменение ухудшает смежное поведение» (PROCESS: жёлтый вердикт правомерен и при выполненных AC, если задета соседняя функциональность). В скоупе задачи — правится в этой же задаче, отдельный issue не заводится (#202). Воспроизведение (готовая команда): `node demo/smoke_zoom_out.mjs` на `HEAD` — красный, `viewCenterRestored: expected true, got false`. ### Low (не блокируют, решение ревьюера — снято с записью) - **L1** — `_prepareCameraCommand`, `_bootSoftCancel`, `_stagePointerDown` (`houseplan-card.ts`) объединяют по два независимых оператора в одну строку через `;` (например `this._bootSoftCancel(); if (this._modeTransitionBusy) this._cancelModeTransition(true);`). Работает корректно, но снижает читаемость и диффопригодность по сравнению со стилем остального файла. Не требую правки — точечная правка форматирования не стоит отдельного цикла ревью; можно поправить попутно при фиксе M5, раз файл всё равно будет тронут. - **L2** — та же строка `import { measuredCardHeaderHeight, settleSoftStageLayout } from './boot-soft-layout';` дописана в конец существующей многострочной секции импорта `viewport-transition` через `;` вместо отдельного `import`-блока (`houseplan-card.ts` diff в области строки 321-324). Работает, но нарушает обычный стиль одного импорта на блок. - **L3** — `src/summary-panel-identity.ts`: `structuralPath()` по-прежнему строит идентичность через `children.indexOf(node)` на каждом уровне до ближайшего `hui-card`. Кеш (`WeakMap`, ключ — сам DOM-узел обёртки) закрывает конкретный сценарий из теста-свидетеля (реордер колонок Masonry **в одной сессии** не меняет ключ) — то есть закрывает ровно то, что просил r1. Но ТЗ §8.2 буквально требует для Masonry «индекс в исходном упорядоченном `cards`, не номер визуальной колонки»; реальная разметка `hui-masonry-view` недоступна в demo-стенде (тот же повод, что был у r1 — «разобрано чтением, не воспроизведено исполнением»), поэтому не могу подтвердить или опровергнуть, что **первое** вычисление пути (до попадания в кеш, например после полной перезагрузки страницы) совпадает с логическим индексом карточки, а не с колонкой, в которую её в этот раз распределил алгоритм балансировки высот. Снимаю как Low, а не Medium: конкретный дефект, который просил доказать r1 (нестабильность при живом реордере), закрыт и подтверждён исполнением; это уже архитектурное сомнение за пределами того, что можно воспроизвести в этом стенде. ## Что проверено и корректно - M1–M4 из r1 закрыты по существу (см. таблицу выше), с независимым воспроизведением там, где это было возможно исполнением, и явной пометкой «прочтением» для L3. - `af441149` (сортировка backend-импорта) и `336d8f30` (правка `smoke_danger_confirm_branches.mjs` под инертный `