30 KiB
CODE-REVIEW-152-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/152
- Ветка:
issue/152-room-click-fit - SHA материала:
a54addb694a38baad7fe289c7eb9ab3d90c99704 - Диапазон:
origin/dev..HEAD(merge-based5bfa0e3) - Заход: r1 (код-ревью) · блокирующих циклов израсходовано 0 из 4 на этом этапе
- ТЗ:
docs/specs/152-room-click-fit.md(r2, зелёное ревью ТЗ подтверждено вdocs/reviews/SPEC-REVIEW-152-r2.md, коммитf672de67)
Скоуп
10 коммитов от f29c4c5b (спека) до a54addb6 (принятие provenance
скриншотов). Продуктовый код: новый src/room-fit.ts (чистая геометрия/hit
ownership), интеграция в src/houseplan-card.ts (_stagePointerDown/Move/Up/Cancel,
_fitRoom, _roomFitBounds, _renderRoomLabel, resize-refit), новый
CameraTransitionReason: 'room' в src/viewport-transition.ts, механический
вынос части существующих хелперов в новый src/card-runtime.ts (лимит размера
основного файла), 4 новых мутанта в scripts/mutation-gate.mjs, поднятие
bundle:budget потолка 294 000 → 296 000 Б (документировано), i18n en/ru/de/fr,
demo/smoke_room_fit.mjs, test/room-fit.test.mjs, документация
(CANVAS/ISOMETRIC/TESTING/USER-GUIDE/CHANGELOG×2).
Трейлеры: все коммиты несут Issue: #152; User-Visible: yes только на
4aad9fac (фича) — тот же коммит правит docs/CHANGELOG.md и
docs/CHANGELOG.ru.md. Остальные коммиты — User-Visible: no (тесты,
документация, фикс без пользовательского эффекта). Соответствует правилу.
Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| typecheck | npx tsc --noEmit |
зелёный, без вывода |
| unit | npm test |
1897 tests · 1896 pass · 0 fail · 1 skip (совпадает с заявлением автора) |
| build | npm run build |
зелёный, dist пересобран |
| bundle sync | npm run bundle:sync |
зелёный; git status после пересборки чист — три копии бандла побайтно совпадают с коммитом |
| bundle budget | npm run bundle:budget |
зелёный: initial View 294 862 Б при потолке 296 000 (±2000); предупреждение о запасе 5 138 Б — это раскрытый и обоснованный в самом диффе долг #367, не новая находка |
| docs fingerprint | node scripts/check-docs.mjs |
зелёный (diff трогает src/**, гейт обязателен) — «7 files, 12 external links» |
| mutation-gate AC6 | node scripts/mutation-gate.mjs --id=room-fit-interactive-owner-leaks-through |
мутант поймал: smoke_room_fit.mjs краснеет |
| mutation-gate AC7 | node scripts/mutation-gate.mjs --id=room-fit-pan-release-reaccepted |
мутант поймал |
| mutation-gate AC8 | node scripts/mutation-gate.mjs --id=room-fit-enters-kiosk-double-tap-sequence |
мутант поймал |
| mutation-gate AC10 | node scripts/mutation-gate.mjs --id=room-fit-html-overlay-jumps-ahead |
мутант поймал |
| smoke (AC-named) | node demo/smoke_room_fit.mjs |
зелёный, все 14 свидетелей true |
| smoke (AC9) | node demo/smoke_smooth_zoom.mjs |
зелёный |
smoke (smoke-select, сильная связь на затронутый _stagePointerCancel) |
node demo/smoke_editor_gestures.mjs |
зелёный |
smoke (smoke-select, сильная связь на _holdFired/_panStart/_pinchStart/_swipeStart) |
node demo/smoke_long_press_gesture.mjs |
зелёный |
smoke (smoke-select, прямое совпадение _applyView/_baseVb/_clampView/_panStart/_stageEl/_view) |
node demo/smoke_pan_any_zoom.mjs |
КРАСНЫЙ — необработанное исключение, см. High-1 |
| smoke (smoke-select, прямое совпадение) | node demo/smoke_infinite_canvas.mjs |
зелёный |
| smoke (smoke-select, прямое совпадение) | node demo/smoke_kiosk_pan_lock.mjs |
зелёный |
| smoke (smoke-select, прямое совпадение) | node demo/smoke_decor.mjs |
зелёный |
| smoke (smoke-select, прямое совпадение) | node demo/smoke_furniture.mjs |
зелёный |
smoke (та же семья вызова _stagePointerUp плоским объектом) |
node demo/smoke_backdrop.mjs, node demo/smoke_hide_layers.mjs |
зелёные — крашится только View-ветка smoke_pan_any_zoom |
| ручная мутация AC15 (нет в реестре, см. Medium-2) | правка houseplan-card.ts, bundle:sync, node demo/smoke_room_fit.mjs, откат |
roomIntentIsSessionOnly перевернулся в false — защита реальна, но не зарегистрирована |
| ручная проверка AC14 в редакторах (см. Medium-1) | ad-hoc браузерный скрипт через demo/serve.mjs, _mode='devices'/'decor' |
role="button" tabindex="0" aria-label="Fit room Office" присутствуют, Enter/Space не производят эффекта |
| golden | не запускал | AC3/AC4 сами называют метод unit + golden, но план тестирования спеки прямо откладывает golden на пре-бету (docs/specs/152-room-click-fit.md §«План тестирования»: «Golden, полный smoke и performance — перед beta»), это же в PROCESS.md §8. Не расхождение, а согласованное разделение обязанностей |
backend (pytest tests_backend) |
не запускал | diff не трогает custom_components/**/*.py |
invariants (npm run invariants) |
не запускал | diff не меняет модель стен/комнат/layout/marker.space/open_spans — правится только камера |
полный demo/smoke_*.mjs (220 файлов) |
не запускал | непропорционально: диапазон правит camera/pointer-подсистему View, а не «всё»; выбор по smoke-select.mjs плюс прямые попадания выше признан достаточным, кроме описанной ниже находки |
| performance_smoke | не запускал | не названо в AC, perf-бюджет описан только как «не регрессирует» и закрыт бандл-бюджетом + отсутствием новых DOM-чтений (см. код-чтение AC16) |
node scripts/smoke-select.mjs --base origin/dev --head HEAD вывод: 5 изменённых
файлов src/**, 101 символ на изменённых строках, порог «широкого» символа — 44
смока. 59 файлов — «прямое совпадение», ядро (smoke_room_fit,
smoke_smooth_zoom, smoke_editor_gestures, smoke_long_press_gesture,
smoke_pan_any_zoom, smoke_infinite_canvas, smoke_kiosk_pan_lock,
smoke_decor, smoke_furniture) прогнан выше; остальные — совпадения по
одному распространённому символу (_mode, _baseVb, _view, _zoom,
stopPropagation, _applyView), которые сами инструмент и правило ревью
относят к «слабой связи» («повод посмотреть, а не обязанность прогонять»); я
их не прогонял. Полная матрица (220 файлов) — обязанность пре-релиза
(PROCESS.md §8), не этого цикла.
Находки
High-1. demo/smoke_pan_any_zoom.mjs падает необработанным исключением — новый код не переживает существующий вызов _stagePointerUp
Файл: src/houseplan-card.ts:6220-6223 (_roomOwner), вызов из
_stagePointerUp (src/houseplan-card.ts:6941 и далее, this._roomOwner(ev)
на аргументе acceptedRoomFitGesture).
Воспроизведение: на SHA a54addb6, чистое дерево, свежий bundle:sync:
$ node demo/smoke_pan_any_zoom.mjs
...
page.evaluate: TypeError: e.composedPath is not a function
at yg._roomOwner (…/houseplan-card-CAJGhl9a.js:4951:48660)
at yg._stagePointerUp (…/houseplan-card-CAJGhl9a.js:4959:10851)
at pu (eval at evaluate…)
at drag (eval at evaluate…)
at async inMode (eval at evaluate…)
Node.js v22.23.2
Скрипт вызывает _stagePointerUp({ pointerId, clientX, clientY }) —
лёгкий объект вместо настоящего PointerEvent — паттерн, которым в этом же
демо-наборе пользуются ещё три смока (smoke_backdrop.mjs,
smoke_editor_gestures.mjs, smoke_hide_layers.mjs); все три остаются
зелёными, потому что вызывают _stagePointerUp только вне _mode==='view',
где _roomOwner рано возвращает null и до ev.composedPath() дело не
доходит. smoke_pan_any_zoom.mjs — единственный, кто гоняет ровно этот сценарий
(drag по пустому месту) в _mode==='view' (inMode('view', …) — первый вызов
в скрипте), и там _roomOwner пытается прочитать ev.composedPath()
безусловно.
Причина: _stagePointerUp теперь вычисляет this._roomOwner(ev)
безоговорочно (как аргумент acceptedRoomFitGesture), даже когда
this._roomPointer уже null и результат гарантированно будет отброшен
acceptedRoomFitGesture's if (!candidate || …) return null;. JS вычисляет
аргументы до вызова функции, так что защита внутри acceptedRoomFitGesture не
спасает — ev.composedPath() читается раньше.
В проде не воспроизводится — единственный продуктовый вызов
_stagePointerUp идёт из @pointerup=${(e: PointerEvent) => this._stagePointerUp(e)}
(houseplan-card.ts:11556) с настоящим DOM-событием, у которого
composedPath() есть всегда. Но это не снимает находку: demo/smoke_pan_any_zoom.mjs
входит в обязательный пре-релизный набор (PROCESS.md §8, smoke — гейт
validate.yml) и сейчас красный на этом SHA, а не только «не прогнан
автором» — это уже сломанный существующий тест, а не пропущенный.
Почему High, а не Medium: это не гипотетический край, а воспроизводимый крах существующего, ранее зелёного гейта, обязательного перед бетой; ревью не может подтвердить «оно работает», когда прогон падает необработанным исключением, а не assertion-ошибкой.
Предполагаемое направление фикса (не мой мандат — решает автор): либо
защитить _roomOwner/roomFitOwnerFromPath от отсутствия composedPath,
либо не звать this._roomOwner(ev) вообще, когда this._roomPointer уже
null (это заодно и дешевле — не ходит по composedPath() на каждый
posterup, у которого не было room-кандидата).
Medium-1 (в скоупе). Подпись комнаты в редакторах Devices/Decor получает нерабочую клавиатурную кнопку — нарушает собственный AC14
Файл: src/houseplan-card.ts:12798-12804 (_renderRoomLabel): role,
tabindex, aria-label и @keydown гейтятся условием !this._markup, а
_markup (houseplan-card.ts:1707) — это this._mode === 'plan', то есть
только редактор Plan. В Devices (_mode==='devices') и Decor
(_mode==='decor') те же атрибуты присутствуют.
Воспроизведение (ad-hoc браузерный скрипт через demo/serve.mjs, комната
с именем и disp.showNames: true):
c._mode = 'devices'; c.requestUpdate(); await c.updateComplete;
// .roomlabel[data-id="room-a"]:
// role="button" tabindex="0" aria-label="Fit room Office"
label.focus();
label.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', … }));
// → c._view не изменился, c._roomFocus остался null
c._mode = 'decor'; // тот же результат: role="button" tabindex="0" aria-label="Fit room Office"
_roomLabelKey (houseplan-card.ts:7032) начинается с
if (this._mode !== 'view' || …) return;, так что Enter/Space в Devices/Decor
не делают ничего — но screen reader и клавиатурный пользователь видят
фокусируемую кнопку «Вписать комнату Office», которая молча ничего не
исполняет.
Нарушенный AC: AC14 прямым текстом: «…editors не получают room action». Раздел «Touch, темы и режимы» ТЗ называет явно Plan/Backdrop, но общий принцип раздела «Клавиатура и доступность» — target есть только «в View/kiosk» — тоже нарушен для Devices/Decor.
Почему Medium, не High: не ломает основной сценарий (клик/тап в View),
не искажает данные, задевает только клавиатурных/screen-reader пользователей
внутри admin-only редакторов. Фикс тривиален и в скоупе: заменить
!this._markup на this._mode === 'view' в атрибутах role/tabindex/
aria-label и в условии @keydown подписи.
Medium-2 (в скоупе). Защита AC15 (запрет _saveZoom для reason room) не зарегистрирована в mutation-gate, хотя доказывается только дорогим смоком
Файлы: src/houseplan-card.ts:1122 (if (state.reason !== 'room') this._saveZoom();)
и :1142 (if (presentedZoom !== undefined && reason !== 'room') this._saveZoom();).
Спека сама формулирует AC15 так: «Mutation, направляющая reason room в
_saveZoom, краснит storage spy» — то есть обещает мутанта. План тестирования
относит AC15 к «дешёвым pure unit», которые ревьюер проверяет локальным снятием
защиты. По факту защита доказана только внутри demo/smoke_room_fit.mjs
(roomIntentIsSessionOnly) — не pure-unit тестом (test/viewport-transition.test.mjs
не тронут вообще этим диффом) — то есть именно «дорогим гейтом (смок)», для
которого PROCESS.md §2.7 требует обязательного мутанта в
scripts/mutation-gate.mjs: «там ревьюер не воспроизведёт отрицательный прогон
второй раз». Среди 4 новых мутантов диффа (room-fit-interactive-owner-leaks-through,
room-fit-pan-release-reaccepted, room-fit-enters-kiosk-double-tap-sequence,
room-fit-html-overlay-jumps-ahead — все покрывают AC6/AC7/AC8/AC10) мутанта на
AC15 нет.
Я проверил вручную, что защита реальна (иначе это была бы находка High —
непроверяемый AC): временно убрал оба условия reason !== 'room',
bundle:sync, node demo/smoke_room_fit.mjs:
"roomIntentIsSessionOnly": false // было true
FAILED (1): - roomIntentIsSessionOnly: expected true, got false
Откатил правку, bundle:sync, дерево чисто на a54addb6. Защита работает
сейчас — но без записи в реестре она не переживёт следующую правку камеры:
будущий ревьюер (или CI) не узнает, что нужно её защищать, пока баг снова не
доедет до пользователя (ровно сценарий, который описывает сам mutation-gate.mjs
в шапке файла).
Почему Medium, не High: сам факт защиты подтверждён (мутация красит smoke), это пробел в реестре регресс-тестирования, а не текущий баг.
Low (снимаю без правки, с записью)
- AC5 (вложенные комнаты) и AC9 (retarget между двумя разными комнатами
во время tween) не имеют прямого автотеста, обещанного планом
тестирования (
demo/smoke_room_fit.mjsкликает только по одной комнате —room-a; вторая комнатаouterв фикстуре есть, но по ней ни разу не кликают;test/viewport-transition.test.mjsэтим диффом не тронут вообще, хотя план тестирования спеки обещал его расширить). Не поднимаю до Medium: оба свойства следуют по конструкции из переиспользования немодифицированного механизма —roomFitOwnerFromPathчитаетev.composedPath()те же DOM-узлы[data-hp="room"], что уже использует hover (@pointerenter=${enterRoom}на тех же элементах,houseplan-card.ts:11700-11726), и_startCameraTransition(ретаргет от_cameraState(), который читает live_zoom/_view, непрерывно обновляемые во время tween) — код, который #82 уже покрывает для других reason. Проверено чтением, не исполнением. - Механический вынос в
src/card-runtime.ts(strictNumber,lruRead/lruWrite,normalizeMarkupTool,expiredWarmViewport,warmBootKey,warmMatch) побайтно сверен построчным diff с версией наorigin/dev— логика идентична (толькоexport, переименование локальных переменныхs→slot, форматирование), но при переносе потеряны развёрнутые комментарии-обоснования (DEV-B703-01,AUD-159B1-01и др.), объясняющие почему устроено именно так warm-remount кеширование. Не блокирует — история остаётся вgit blame— но будущим читателямcard-runtime.tsбудет сложнее реконструировать контекст.
Таблица «чем краснеет» (защитные AC, PROCESS.md §2.7)
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| AC6 (интерактивный владелец подавляет fit) | node demo/smoke_room_fit.mjs |
node scripts/mutation-gate.mjs --id=room-fit-interactive-owner-leaks-through → красный, прогнано, поймано |
| AC7 (pan/pinch/long press не создают fit) | node demo/smoke_room_fit.mjs |
node scripts/mutation-gate.mjs --id=room-fit-pan-release-reaccepted → красный, прогнано, поймано |
| AC8 (room tap не входит в kiosk double-tap) | node demo/smoke_room_fit.mjs |
node scripts/mutation-gate.mjs --id=room-fit-enters-kiosk-double-tap-sequence → красный, прогнано, поймано |
| AC10 (SVG/HTML синхронны на промежуточных кадрах) | node demo/smoke_room_fit.mjs |
node scripts/mutation-gate.mjs --id=room-fit-html-overlay-jumps-ahead → красный, прогнано, поймано |
AC15 (нет zoom-only записи для reason room) |
node demo/smoke_room_fit.mjs (roomIntentIsSessionOnly) |
не зарегистрирован; ручная мутация (снял оба reason !== 'room') → roomIntentIsSessionOnly: false, прогнано мной, не воспроизводимо CI до фикса (Medium-2) |
| AC11 (повторный fit — no-op) | node demo/smoke_room_fit.mjs (repeatedFitIsNoOp) |
не зарегистрирован; логика (sameCameraState-гард в _startCameraTransition) не изменена этим диффом, унаследована от #82 — не перепроверял мутацией, см. «чего не проверял» |
| AC12 (resize сохраняет focus до ручной команды) | node demo/smoke_room_fit.mjs (resizeRefitsAtomically, panCancelsRoomIntent) |
не зарегистрирован; проверено чтением (_clearRoomFocus() вызывается во всех перечисленных в ТЗ точках отмены) — не перепроверял мутацией |
| AC2/AC3/AC4 (математика полей/zoom/bounds) | node --test test/room-fit.test.mjs (pure unit, test-build/room-fit.js) |
дешёвый unit, я прочитал и мысленно снял защиту (q: 0.8→1, удаление clamp) — assertions считают точные числа, любое искажение ломает close()/assert.equal; не требует мутации по PROCESS.md §2.7 (дешёвый pure unit) |
Что проверено и корректно
- AC1 (чистый click/tap вписывает и центрирует):
smoke_room_fit.mjs→cleanRoomTapFits,oneFlatAxisUsesTenPercentзелёные для mouse; keyboard —keyboardUsesSameCommand. Touch/pen отдельно не гонял (смок используетpointerType: 'mouse'/'touch'в разных секциях — touch есть в kiosk-блоке), pen — проверено чтением: ownership-код (roomFitOwnerFromPath,acceptedRoomFitGesture) не различаетpointerType, значит логика идентична для mouse/touch/pen по конструкции. - AC2 (10% ± 1 px, 2000×1000→800×800):
test/room-fit.test.mjsточно воспроизводит числовой пример спеки; прогнано, проходит. - AC3/AC4 (bounds = ровно floor+wall body, Flat/Iso через точные
проекции): unit-тесты плюс код-чтение
_roomFitBounds— использует те же_innerRoomContour/_wallUnionGeometry/roomWallProfile/outsetContour, что рендер, без DOMgetBoundingClientRectи без второй wall-модели (grepпо диффу — ноль совпадений). Golden — сознательно отложен на пре-бету по плану тестирования спеки. - AC5: проверено чтением — общий DOM-путь с hover (см. Low-1).
- AC6/AC7/AC8/AC10: доказаны и перепроверены мутациями (таблица выше) — все четыре мутанта поймали регресс.
- AC9: проверено чтением,
_startCameraTransitionи_cameraState()не изменены этим диффом кроме добавления reasonroom;smoke_smooth_zoom.mjsзелёный подтверждает, что общий контракт ретаргета/cancel не сломан. - AC11/AC12/AC13: смок зелёный (
repeatedFitIsNoOp,resizeRefitsAtomically,panCancelsRoomIntent); AC13 (invalid geometry/zero stage) — код-чтение_fitRoom/_roomFitBounds(оба возвращаютnull/no-op на невалидных данных без NaN-путей,_roomFitBounds→roomFitGeometryBounds→finitePointфильтрует NaN на входе). - AC14: role/tabindex/aria-label/focus-visible/Enter-Space работают
корректно в View и kiosk —
smoke_room_fit.mjs(labelIsKeyboardAction,keyboardUsesSameCommand); но см. Medium-1 для Devices/Decor. - AC15: гейт работает сейчас (см. Medium-2 про отсутствие мутанта).
- AC16: bundle:budget зелёный; код-чтение подтверждает, что
_roomFitBoundsвызывается один раз на принятый жест/resize (не в_stagePointerMove/RAF), и не читает layout (getBoundingClientRectотсутствует). - i18n:
room.fit_actionдобавлен с parity en/ru/de/fr. - Документация:
docs/CANVAS.md,docs/ISOMETRIC.md,docs/TESTING.md,docs/USER-GUIDE.ru.md, оба CHANGELOG — соответствуют реализации, термины совпадают с фактическим поведением (не проверял на разошедшиеся с кодом утверждения, кроме уже отмеченного разрыва в Medium-1, который документация не выдаёт за факт — таблица USER-GUIDE описывает только View). - Фикс-коммит
6a7ef48a(«preserve editor pointer cancellation») — реальная, обоснованная правка регрессии, внесённой первым коммитом этой же задачи (4aad9fac), не расширение скоупа:_stagePointerCancelраньше считалviewportGestureEndedуже ПОСЛЕ того как host обнулял_pinchStart/_panStart, из-за чего editor-ветка терялаrequestUpdate()после отмены pan/pinch в редакторе. Перепровереноsmoke_editor_gestures.mjs(зелёный) — единственный минус: два оператора на одной строке через;(стилевая мелочь, не блокирует). - Bundle-budget: поднятие потолка задокументировано в самом файле с числами, согласуется с измеренным результатом (294 862 Б, заявлено 294 864 в комментарии — расхождение 2 Б, в пределах шума сборки).
Чего не проверял
- Полный
demo/smoke_*.mjs(220 файлов),npm run golden:capture/golden:verify,performance_smoke— по плану тестирования спеки и PROCESS.md §8 это пре-релизный гейт, не гейт этого цикла; я прогнал целевую выборку (smoke-select + AC-именованные + семью_stagePointerUp-вызовов) и нашёл в ней реальный красный результат (High-1) — до его исправления смысла гонять остальные 200+ файлов не вижу. python -m pytest tests_backend— diff не трогаетcustom_components/**/*.py.npm run invariants— diff не меняет геометрическую модель (комнаты/стены/ layout/marker.space/open_spans); камера не участвует в инвариантах.- Мутации для AC11/AC12 (нет в реестре; логика либо унаследована от #82 без изменений в этом диффе — AC11, либо тривиально читается — AC12).
- Реальное touch/pen устройство и screen reader — по коду и смоку логика не
различает
pointerType, но живого браузера на телефоне/с NVDA не было. - Изометрический golden-скриншот (см. выше — сознательно отложен).
Вердикт
Один блокирующий High (сломанный существующий пре-релизный смок, воспроизведён на текущем SHA) и два Medium в скоупе (нарушение AC14 в Devices/Decor, отсутствующий обязательный mutation-gate на AC15). Все три — чинятся в этой же задаче без расширения скоупа.
Вердикт: красный · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 2 → в задаче
Возврат в S6-in-progress.
Материал раунда
- Ветка:
issue/152-room-click-fit, коммитa54addb694a3— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
042ef7b3005c8e10620427979a9960d36eed0155git log --all --format='%H %T' | grep 042ef7b3005c - ТЗ
docs/specs/152-room-click-fit.md, блобfb036d2bb9cc522555c55e15000d7d693e204f8bgit log --all --find-object=fb036d2bb9cc522555c55e15000d7d693e204f8b -- docs/specs/152-room-click-fit.md