Files
houseplan-card/docs/reviews/CODE-REVIEW-437-r2.md
T
2026-09-08 17:09:16 +00:00

128 lines
23 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-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` под инертный `<style data-hp-summary>` в теневом DOM) — прочитаны, обе точные и по существу: `336d8f30` не маскирует реальный дефект, `<style>`-узел действительно не несёт pointer/decision-поверхности (`src/summary-panel-runtime-loaded.ts:228-235`).
- Оба changelog правятся в одном коммите с `User-Visible: yes` (`3c1a9f8f`, `89976c85`) — трейлеры соблюдены.
- `node scripts/process-gate.mjs` — чисто, трейлеры и имя ветки в порядке.
- Три копии бандла байтово идентичны на `HEAD`.
## Чего не проверял
- `python -m pytest tests_backend -q` в этом раунде — нет HA-harness в окружении ревью; дельта трогает backend только порядком импорта, риск оценён как нулевой, полный прогон наследуется из r1 (396 passed, 3 skipped на `96e07b9a`).
- `npm run golden:verify` и `check-docs --screenshots=strict` — предрелизные гейты, не гейт ревью; дельта не меняет видимый рендер панели.
- `node scripts/model-invariants.mjs` — дельта не трогает геометрию/`layout`/`marker.space`/толщины стен, прогонять не по чему.
- Полная матрица `demo/smoke_*.mjs` (232 файла) — прогнаны только целевые (M1–M4) и камера-центричные из выборки `smoke-select` по дельте; остальные 40 «слабых» совпадений из выборки не прогонялись (общие символы `_config`/`_mode`, вероятность связи с этой дельтой низкая, полный прогон — предрелизный гейт).
- Реальная разметка `hui-masonry-view`/`hui-sections-view` — недоступна в demo-стенде ни в r1, ни в r2 (см. L3).
## Вердикт
Жёлтый. Находки r1 (M1–M4) закрыты и подтверждены. Новая находка этого раунда — **M5**, регресс восстановления камеры View при возврате из редактора: не внесён дельтой r1→r2, но и не был замечен в r1 (смок не входил в выборку гейтов r1) и остаётся красным на материале r2. В скоупе задачи, фикс — в этой же задаче, без отдельного issue.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/437-summary-panel`, коммит `89976c85edc4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `d4d69013ad77f79890655837333acc57f552b765`
```
git log --all --format='%H %T' | grep d4d69013ad77
```
- ТЗ `docs/specs/437-summary-panel.md`, блоб `58a2db80c2161079bc9044c107063e54bfcf0be6`
```
git log --all --find-object=58a2db80c2161079bc9044c107063e54bfcf0be6 -- docs/specs/437-summary-panel.md
```