23 KiB
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на SHA96e07b9a0255eef3a55cd6c3be08198a0adcc7ba. 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.tsdiff в области строки 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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
d4d69013ad77f79890655837333acc57f552b765git log --all --format='%H %T' | grep d4d69013ad77 - ТЗ
docs/specs/437-summary-panel.md, блоб58a2db80c2161079bc9044c107063e54bfcf0be6git log --all --find-object=58a2db80c2161079bc9044c107063e54bfcf0be6 -- docs/specs/437-summary-panel.md