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

23 KiB
Raw Blame History

CODE-REVIEW-437-r2

Issue: #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.


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

  • Ветка: 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