31 KiB
CODE-REVIEW-449-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/449
- Этап: код-ревью (PROCESS.md §2.7), заход r1, блокирующих циклов израсходовано 0/4
- ТЗ:
docs/specs/449-double-fit-all.md(трек полный) - SHA материала:
05ef318133ca54b1f702844253d06edd5a665c5e(единственный код-коммит на ветке сверхorigin/dev) - Ветка:
issue/449-double-fit-all
Скоуп
Один коммит feat: fit all on double background tap:
src/houseplan-card.ts,src/room-fit.ts— общийDoubleFitGestureRecognizer, единый gesture owner (planGestureOwnerFromPath), замена kiosk-only_lastTapна разделяемый со View контракт,_fitAllпринимает reasondouble-tap.test/room-fit.test.mjs— unit-покрытие recognizer/owner.scripts/mutation-gate.mjs— 4 новых мутанта + правка 2 существующих под новую форму кода.demo/smoke_room_fit.mjs,smoke_kiosk.mjs,smoke_kiosk_pan_lock.mjs,smoke_smooth_zoom.mjs— расширенные сценарии.docs/CANVAS.md,docs/TOUCH-SUPPORT.md,docs/UX-MODES.md,docs/USER-GUIDE.md(.ru), обаCHANGELOG— синхронно,User-Visible: yes.dist/**— пересобран.
Ревью полное (первый заход).
Как проверялось — таблица гейтов
Зелёного Validate на этом SHA нет, все гейты ниже прогнаны локально мной.
| Гейт | Команда | Результат |
|---|---|---|
| Типы | npx tsc --noEmit |
зелёный |
| Юниты | npm test |
красный: 3 из 1914 (#438 полоса шире наблюдаемого шума метрики, манифест бандла не ссылается в никуда... (#349), release ZIP inspection is portable...). Разобрано ниже (H1, M1) |
| Сборка | npm run build |
зелёный (детерминированно собирается, хеши чанков меняются между прогонами — ожидаемо, эмбеддед fingerprint) |
| Три копии бандла | cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js на закоммиченном HEAD |
красный: differ: byte 45, line 1 — см. H1 |
| any-гигиена | node scripts/no-new-any.mjs --base origin/dev --head HEAD |
зелёный: «Новых any нет» |
docs-гейт (диф трогает src/**) |
node scripts/check-docs.mjs |
красный (exit 1): «screenshot source fingerprint is stale» — см. H2. На origin/dev тот же скрипт зелёный, регрессия подтверждена сравнением |
| Подбор смоков | node scripts/smoke-select.mjs --base origin/dev --head HEAD |
«Прямое совпадение» — 35 файлов (полный список приведён ниже) |
| Прямые смоки (35 из «прямое совпадение») | node demo/smoke_*.mjs по каждому |
34 PASS, 1 FAIL (smoke_kiosk_pan_lock.mjs) — см. H3 |
| Мутанты #449 (4 новых) | node scripts/mutation-gate.mjs --id=<id> × 4 |
все 4 «тест покраснел, как обязан» |
| Мутанты #152, тронутые рефактором (5 существующих) | node scripts/mutation-gate.mjs --id=<id> × 5 |
4 «покраснел», 1 «остался зелёным на сломанном коде» (room-fit-enters-kiosk-double-tap-sequence) — см. M1 |
| Инвентарь | npm run inventory |
1914 unit / 308 pure backend / 186 HA-harness / 221 smoke — справочно |
| golden:verify | не прогонялся | diff не трогает рендер/стили/геометрию — новый жест не рисует ничего нового; см. «Чего не проверял» |
| pytest tests_backend | не прогонялся | diff не касается custom_components/**/*.py |
| model-invariants | не прогонялся | diff не касается геометрии/layout/толщины стен |
Working tree после каждого гейта, менявшего файлы (npm run build,
npm run bundle:sync, mutation-gate), возвращён к состоянию коммита
(git checkout -- . && git clean -fd на затронутых путях) — в репозитории
никаких изменений не оставлено.
Прямое совпадение smoke-select (35) — решение по каждому
smoke_decor.mjs, smoke_editor_gestures.mjs, smoke_grid_scale_invariance.mjs, smoke_room_fit.mjs, smoke_decor_default_persist.mjs, smoke_furniture.mjs, smoke_lazy_editor_chunk.mjs, smoke_long_press_gesture.mjs, smoke_modes.mjs, smoke_support_feedback.mjs, smoke_editor_tabs.mjs, smoke_isometric_contract.mjs, smoke_junction_patch_resilience.mjs, smoke_resize_pointer_real_plan.mjs, smoke_room_link.mjs, smoke_smooth_zoom.mjs, smoke_space_tab_reorder.mjs, smoke_warm_dialogs.mjs, smoke_warm_owners.mjs, smoke_zoom_out.mjs, smoke_audit_1490.mjs, smoke_backdrop.mjs, smoke_canvas_frame.mjs, smoke_edit_walk.mjs, smoke_icon_center.mjs, smoke_infinite_canvas.mjs, smoke_kiosk_pan_lock.mjs, smoke_linked_virtual_light.mjs, smoke_pan_any_zoom.mjs, smoke_plan_snap_overlay.mjs, smoke_real_plan_masonry.mjs, smoke_unified_wall_tool.mjs, smoke_vacuum_firstuse.mjs, smoke_vacuum_multifloor.mjs, smoke_vacuum.mjs — все прогнаны (плюс
smoke_kiosk.mjs, названный в AC3 напрямую). Решение: прогнать все 35, а не
выбирать по теме — инструмент отметил их как прямое совпадение по изменённым
символам (_fitAll, _mode, _resetZoom, _roomPointer, _panLock,
_suppressClick, _vacFit, _continuity, _pinchStart, _panStart,
_holdFired, _editorRuntime, _modeTransitionBusy, _stagePointerCancel), а
не по названию. «Слабая связь» (41 файл, одно распространённое имя) не
прогонялась — решение ревьюера: символы там (_editorRuntime,
_modeTransitionBusy и т. п.) относятся к неизменённым веткам этих файлов, а
диф не касается их логики.
Находки
High-1 — custom_components/houseplan/frontend/** не синхронизирован с dist/**: пользователь не получает фичу
Коммит пересобрал и закоммитил dist/** (видно в дифе), но не выполнил
npm run bundle:sync — вторая закоммиченная копия бандла, та, которую
устанавливает HACS и раздаёт интеграция, осталась на предыдущей сборке.
Воспроизведение:
$ cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js differ: byte 45, line 1
$ git log --oneline -1 -- custom_components/houseplan/frontend
8935a736 fix: accept Zigbee2MQTT raw network maps # НЕ #449
$ git log --oneline -1 -- dist
05ef3181 feat: fit all on double background tap
Явное доказательство — файл с новым именем чанка есть только в dist/:
dist/houseplan-assets/houseplan-card-CbNfZuwV.js существует,
custom_components/houseplan/frontend/houseplan-assets/houseplan-card-BgNWi9NC.js
— это старый чанк (имя не поменялось со времён 8935a736, до #449).
Это ровно тот гейт, который PROCESS.md §8 требует прогонять перед выходом из
«В разработке» (npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js) — и он был бы красным
с первой попытки.
Автоматически это же ловят два теста из npm test:
манифест бандла не ссылается в никуда, обе копии целы и равны (#349)(test/bundle-tree-committed.test.mjs) —AssertionError: копии бандла разошлись;release ZIP inspection is portable and does not depend on tar(test/release-contract.test.mjs) — sha256 закоммиченногоdist/houseplan-card.jsне совпадает с тем, что попадёт в релизный zip изcustom_components/houseplan.
Последствие: релиз с этим коммитом отдаёт пользователям HACS-интеграцию без жеста #449 — весь остальной код ревью (тесты, смоки, мутанты) проверяет код, которого реальный пользователь не увидит.
Исправление: npm run bundle:sync и закоммитить результат в этом же коммите.
High-2 — docs-гейт красный: скрипты не пересняты после правки src/**
$ node scripts/check-docs.mjs
ERROR screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs
$ echo $?
1
На origin/dev тот же скрипт зелёный («Documentation checks passed»). Диф
меняет src/houseplan-card.ts и src/room-fit.ts, отпечаток документации
считается по всему src/** — правило, повторённое в задании ревью буквально
с историей: «Пропуск этого шага в #230 и #234 оставил dev с красным job
docs до следующей задачи (#237)».
Исправление: npm run build && node demo/docs/capture.mjs, закоммитить.
ТЗ прямо говорит, что новых скриншотов не требуется («статический вид не
меняется») — но фингерпринт пересчитывается механически по исходникам, а не
по итоговой картинке, так что пересъёмка (даже с побайтово идентичным
результатом, если камера та же) обязательна.
High-3 — заявленное доказательство AC3 красное: demo/smoke_kiosk_pan_lock.mjs
AC3 («Kiosk использует общий contract без регрессии навигации») называет этот смок доказательством. Он падает:
$ node demo/smoke_kiosk_pan_lock.mjs
...
FAILED (1):
- doubleTapStillResetsZoom: expected true, got false
Причина (разобрано инструментированным прогоном сценария): сценарий 4
смока делает home() → _applyView(2) → два клика по фону (id 58, id 60) — всё
синхронно, без ожидания макротаска. Перед этим сценарий 1–3 гоняет реальные
pan-жесты, последний из которых (straightPan, id 57) взводит
this._suppressClick = true на pointerup. Сброс отложен:
// src/houseplan-card.ts, _stagePointerUp
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
_doubleFitEnabled требует !this._suppressClick. Если следующий
pointerdown/pointerup (первая и вторая половина double-tap) происходит до
срабатывания этого setTimeout(0), _doubleFitEnabled остаётся false для
обеих половин — кандидат не взводится вовсе, _fitAll не вызывается ни
разу. Прямая проверка (инструментированный повтор того же сценария):
debugZoom (после двух тапов id 58/60) = 2 // не изменился
debugDirectFitZoom (сразу же вызванный _fitAll('fit')) = 1 // корректная цель
Итог: не «другая цель», а полностью проглоченный жест.
Это регрессия именно #449: до этой задачи kiosk-сброс зума не проверял
_suppressClick вовсе (старая ветка _lastTap в _stagePointerUp читала
только _swipeStart/движение). Начиная с этого коммита kiosk-таблица
double-tap разделяет общий _doubleFitEnabled, и получила вместе с ним новую
гонку между отложенным сбросом _suppressClick и следующим касанием.
В реальном использовании окно гонки — один macrotask (~0 мс), обычный человек вряд ли попадёт в него пальцем сразу после панорамирования; но ровно это и проверяет смок синхронной подачей событий, и он назван ТЗ как обязательное доказательство AC3 — и это доказательство ложно-зелёным не является, оно честно красное.
Исправление на усмотрение автора: либо не гейтить взвод первой половины
double-tap через _suppressClick (у него уже есть отдельная роль — подавлять
одиночный click), либо синхронизировать сброс (например, сбрасывать
_suppressClick сразу, а не через setTimeout(0), раз причина отложенности
была про порядок с нативным click-событием — нужно перепроверить, не сломает
ли это исходный сценарий #396). Смок либо чинится по коду, либо (если решение
— «это приемлемая деградация в 1 macrotask») ТЗ и смок обновляются вместе, с
объяснением, а не молча.
Medium (в скоупе — чинится в этой же задаче)
Medium-1 — bundle budget: запас ушёл ниже шумовой полосы
$ npm test → not ok: "#438 полоса шире наблюдаемого шума метрики"
error: 'сверху меньше 500 Б — это шум'
dist/houseplan-assets.json.initialViewGzipBytes: на origin/dev — 295066 Б,
на этой ветке — 295743 Б (+677 Б от нового recognizer/теста). Потолок 296000 Б
± 2000. Запас сократился с 934 Б до 257 Б — меньше требуемых тестом 500 Б.
npm run bundle:budget формально ещё проходит (потолок не пробит), но ратчет
собственным тестом сигналит: рост наблюдаемый и заметный, тест обязан либо
увидеть поднятый потолок в этом же коммите (документированная практика
ратчета, см. комментарии #367 в test/bundle-assets.test.mjs), либо код
нужно уменьшить. AC11 прямо заявляет «initial bundle проходит действующий
потолок» и ссылается на bundle:budget — сейчас его более строгая версия в
npm test красная.
Исправление: поднять INITIAL_VIEW_GZIP_CEILING/INITIAL_VIEW_CEILING_BAND
в scripts/bundle-budget.mjs вместе с этим коммитом, либо срезать рост.
Medium-2 — мутант room-fit-enters-kiosk-double-tap-sequence больше ничего не ловит
$ node scripts/mutation-gate.mjs --id=room-fit-enters-kiosk-double-tap-sequence
ok чистый прогон: node demo/smoke_room_fit.mjs
FAIL room-fit-enters-kiosk-double-tap-sequence: тест остался зелёным на сломанном коде
a room-owned tap must not update the kiosk free-background double-tap sequence
or unexpectedly reach the whole plan (#152 AC8)
Мутация убирает !acceptedRoom из guard'а kiosk-ветки _stagePointerUp. До
#449 внутри этого же if жила double-tap-логика (_lastTap), поэтому снятие
guard'а реально пускало room-релиз в double-tap-последовательность. #449
вынес double-tap целиком в this._doubleFit.pointerUp(...), вызываемый
до этого if и независимо от acceptedRoom — у него своя защита через
owner.kind !== 'background'. Мутация в старом месте теперь не трогает ничего
значимого, smoke_room_fit.mjs остаётся зелёным не потому что защита жива, а
потому что мутированный код больше ни на что не влияет.
Функционально дыры нет — то же самое поведение (room-таб не взводит
double-fit) отдельно и прямо проверяется новым unit-тестом
(#449 moved, cancelled, multitouch, foreign-owner... со входом
{ owner: { kind: 'room', roomId: 'room-a' } }) и новыми полями смока
(areaLinkSuppressesRoomFit, kioskRoomTapDoesNotEnterDoubleTap) — оба
прошли. Проблема узкая: конкретно этот исторический свидетель ослеп и будет
молча стоять в дереве, выдавая мнимое покрытие любому, кто позже тронет этот
код и понадеется на мутацию как на страховку (тот самый паттерн #421/#423/#430
из вводной к этому ревью).
Исправление: переписать find/replace мутанта на актуальное место
защиты (input.owner.kind !== 'background' в completeDoubleFitPointer) —
дублировать с double-fit-free-background-owner-removed не обязательно, но
нынешний текст мутанта нужно удалить или перенаправить на реальный код.
Low
Low-1 — AC5 для device/opening доказана только чтением, не смоком
ТЗ (AC5) обещает «targeted production-bundle smoke для device, opening и room
link». Смок получил только room link
(areaLinkSuppressesRoomFit в smoke_room_fit.mjs); double-click по .dev/
.opening в живом DOM никакой новый смок не проверяет.
Проверено чтением: planGestureOwnerFromPath матчит .dev, .opening,
.op-hit, .oplock, .vacpuck в ROOM_FIT_INTERACTIVE_OWNER
(src/room-fit.ts), эти классы реально проставлены на отрендеренных узлах
(src/houseplan-card.ts:12547 — .dev, :13061/:13067 — .opening/
.op-hit); _doubleFit.pointerDown вызывается безусловно на каждый
pointerdown до любых mode-specific ранних return, так что реальный тап по
устройству/проёму в View корректно резолвится как interactive до того, как
путь дойдёт до .stage. Юнит-таблица planGestureOwnerFromPath в
test/room-fit.test.mjs покрывает .dev/.opening/[data-room-fit-block]
явно. Риск невысокий, но обещанный смок не доставлен — оставляю как Low,
автор вправе снять запись, если считает юнит-покрытие достаточным (тогда
ТЗ-пункт AC5 по факту доказан частично смоком, частично кодом — стоит явно
пометить в задаче).
Таблица «AC → чем доказан → чем краснеет»
| AC | Доказательство | Статус | Чем краснеет |
|---|---|---|---|
| AC1 mouse double-click = toolbar fit-all | smoke_room_fit.mjs: normalViewDoubleClickMatchesFitAll, normalViewUsesDoubleTapReason |
PASS | не проверялся отдельным мутантом; double-fit-bypasses-canonical-fit-all мутант ловит подмену _fitAll→_resetZoom |
| AC2 touch/pen double-tap, без смешения modality | smoke_room_fit.mjs: mixedModalitiesDoNotPair, penPairUsesSameFitAll; unit «#449 an expired tap…» |
PASS | unit-таблица с явными assert.equal на смешанной модальности |
| AC3 kiosk parity | smoke_kiosk.mjs PASS, smoke_kiosk_pan_lock.mjs FAIL |
FAIL | см. High-3 |
| AC4 комната вне sequence | smoke_room_fit.mjs: kioskRoomTapDoesNotEnterDoubleTap PASS; мутант room-fit-enters-kiosk-double-tap-sequence |
функционально PASS, свидетель ослеп | см. Medium-2 |
| AC5 interactive owners подавляют жест | unit-таблица owner (room/device/opening/link/button); смок только для room link | частично PASS | мутант double-fit-free-background-owner-removed ловит снятие owner-guard'а целиком (не по каждому классу отдельно) |
| AC6 навигационные жесты не завершаются как double-tap | unit «#449 moved, cancelled, multitouch…»; smoke_room_fit.mjs: cancelDisarmsThePreviousTap; smoke_kiosk_pan_lock.mjs (кроме сценария 4) |
PASS (кроме пересечения с High-3) | мутант double-fit-navigation-block-ignored |
| AC7 редакторы не меняются | smoke_editor_gestures.mjs PASS; smoke_room_fit.mjs: editorBackgroundDoesNotFit PASS; unit {mode:'decor'}/{mode:'plan'} |
PASS | мутант double-fit-editor-mode-enabled |
| AC8 sequence изолирована, 2 инстанса | unit «#449 recognizer instances keep independent transient sequences» | PASS (проверено чтением для mode/space/projection lifecycle — см. ниже) | нет отдельного мутанта; риск низкий, логика самокоррекции при следующем pointerDown разобрана в тексте ревью |
| AC9 camera transition единый | код идентичен _fitAll('fit'|'home'), третий reason не создаёт новый путь; smoke_smooth_zoom.mjs (card._fitAll('double-tap')) PASS |
PASS, проверено чтением + существующий smoke | наследует защиту #82, отдельного нового мутанта нет — обоснованно, путь не новый |
| AC10 нет server/HA side effects | код-ревью: _fitAll/DoubleFitGestureRecognizer не содержат callService/save/навигации |
PASS, проверено чтением | не проверялось отдельным spy-смоком; риск низкий (чистые функции + переиспользование существующей команды) |
| AC11 perf/bundle budget | no-new-any зелёный; npm run bundle:budget зелёный (с предупреждением о низком запасе — не новый долг, #367); npm test bundle-assets — FAIL |
FAIL | см. Medium-1 |
Что проверено и корректно
- Чистая типизация,
no-new-any— новый код не добавляетany. - Архитектура recognizer'а (
beginDoubleFitPointer/completeDoubleFitPointer— чистые функции без DOM/side effects,DoubleFitGestureRecognizer— тонкая обёртка с try/catch fail-safe наcomposedPath()) соответствует принятым предположениям ТЗ (Pointer Events, без нового timer/RAF на первом тапе). Подтверждено чтением: нетsetInterval/новых RAF-циклов,pointerDownделает ровно один owner-lookup и O(1) запись._doubleFit.clearOutsideкорректно закрывает случай, когда фокус переходит на элемент вне stage (composedPath не долетает до.stage). - Общий owner (
planGestureOwnerFromPath) — единственный источник для room-fit и double-fit, расхождения между #152 и #449 нет (заявленное в ТЗ требование выполнено). - 33 из 34 unit-тестов
test/room-fit.test.mjs(включая новые для #449) прочитаны целиком: таблицы owner/candidate/sequence покрывают позитивные и отрицательные пути с явными ожиданиями, тест умеет падать — проверено 3 целевыми мутантами (см. таблицу выше), все три поймали свою поломку. Четвёртый мутант (double-fit-bypasses-canonical-fit-all, черезsmoke_room_fit.mjs) тоже поймал. Итого 4 из 4 новых мутантов ловят — «сколько мутантов принесла задача» здесь ненулевое и рабочее, в отличие от старого свидетеля (Medium-2). docs/CANVAS.md,docs/TOUCH-SUPPORT.md,docs/UX-MODES.md,docs/USER-GUIDE.ru.md/.mdобновлены по существу: терминология («Вписать всё», «Просмотр», «киоск») соответствует уже принятой вUSER-GUIDE.ru.md, ничего не изобретено.UX-MODES.mdзаодно поправил устаревшую фразу «room taps do nothing since v1.40.1» на актуальное «room click/tap → room fit» — попутная, но верная и мелкая правка синхронно с темой строки.- Оба
CHANGELOG(RU/EN) правлены в этом же коммите, запись пользовательская, без внутренних имён — соответствуетUser-Visible: yes. - «Одно число — один источник»: диф не вводит новых видимых пользователю чисел (zoom/viewBox не показываются как текст), контроль неприменим.
- Мутация
double-fit-bypasses-canonical-fit-allявно доказывает, что жест не подменяет канонический_fitAllна локальный_resetZoom, — то самое требование ТЗ «Второй controller... запрещён».
Чего не проверял
npm run golden:verify— не прогонял. Diff не меняет разметку, стили, геометрию или что-либо рисуемое; новый жест управляет только камерой тем же путём, что уже покрытые golden-сценариями «Вписать всё»/kiosk-reset. Риск визуальной регрессии считаю нулевым; предрелizный гейт всё равно прогонит его перед бетой.python -m pytest tests_backend— не прогонял, diff не касаетсяcustom_components/**/*.py.node scripts/model-invariants.mjs— не прогонял, diff не меняет геометрию,layout,marker.spaceили толщину стен.- Полный набор
demo/smoke_*.mjs(221 файл) — не прогонял целиком, это предрелизная обязанность (§8), не гейт ревью; прогнал прямое совпадение (35) целиком и это уже поймало регрессию (High-3). - «Слабая связь» smoke-select (41 файл) — не прогонял, символы там не относятся к затронутым веткам логики.
- Реальное устройство/браузер (ручное тестирование) — вне цикла ревью по правилам PROCESS.md §2.7; вместо этого — код-чтение и автотесты.
- Perf-профили — не названы в AC сверх bundle budget, не прогонял.
Итог
3 High (шипуется без фичи в реальном пакете; docs-гейт красный; названное
доказательство AC3 красное) + 2 Medium в скоупе (bundle-budget headroom;
ослепший мутант) + 1 Low (снято с пометкой — неполный, но компенсированный
чтением, набор смоков для AC5). Присутствие High делает вердикт красным
независимо от Medium/Low.
Все три High и оба Medium чинятся в этой же задаче без переоценки скоупа:
npm run bundle:sync + npm run build && node demo/docs/capture.mjs (в
комплекте один локальный проход npm test/cmp/check-docs, чтобы не
разъезжаться повторно), правка гонки _suppressClick↔double-tap (или
осознанная правка смока+ТЗ, если деградация признана приемлемой), подъём
bundle-budget потолка и ремонт/удаление ослепшего мутанта.
Материал раунда
- Ветка:
issue/449-double-fit-all, коммит05ef318133ca— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
548b50a1b777369620551358d0288bfc879c2b5bgit log --all --format='%H %T' | grep 548b50a1b777 - ТЗ
docs/specs/449-double-fit-all.md, блобd6ddccbb3920bdffbb8414f4b6c225a4cdf4f420git log --all --find-object=d6ddccbb3920bdffbb8414f4b6c225a4cdf4f420 -- docs/specs/449-double-fit-all.md