Files
houseplan-card/docs/reviews/CODE-REVIEW-396-r1.md
T
2026-08-31 01:37:12 +03:00

19 KiB
Raw Blame History

CODE-REVIEW-396-r1

  • Issue: #396 — «Плавная камера #82: прерванный зум не сохраняется, якорь уезжает, glow-feather не заморожен»
  • Этап: код-ревью (PROCESS.md §2.7), заход r1, блокирующих циклов израсходовано 0/4
  • SHA материала: c1b6a01bcd72508288d26c7713c60dbdfe42322f (origin/issue/396-camera-transition-fixes)
  • База: origin/dev = 8955c02e3a120e60092547adaf62743c0b042c4a
  • Вердикт: зелёный · High: 0 · Medium: 0

Методологическая находка перед разбором (не дефект кода)

Окружение изначально выдало детач-HEAD на 4e07e8ec — это вершина одного только спек-ревью (docs/reviews/SPEC-REVIEW-396-r{1,2,3}.md + docs/specs/396-camera-transition-fixes.md), без единой строки продуктового кода. git diff origin/dev...4e07e8ec показывал только 4 файла документации. Реальная реализация (4c057fa0 + c1b6a01b) уже существовала на origin/issue/396-camera-transition-fixes, но не была вытянута в рабочую копию к началу цикла. Переключился на неё (git checkout c1b6a01b) и разбирал именно это состояние — это и есть SHA материала выше. Отмечаю это явно, чтобы не потерялось: если бы вердикт подводился на исходном чекауте, «оно вообще работает» ответить было бы нечем.

Скоуп

Диапазон origin/dev...HEAD (6 коммитов): весь трек #396 — 3 раунда спек-ревью, коммит реализации 4c057fa0 (fix: the camera keeps the zoom you see and the point you hold) и c1b6a01b (build: refresh bundle trees and the doc capture). Продуктовый код: src/houseplan-card.ts (38 строк), тесты: test/viewport-transition.test.mjs (+93), test/golden-matrix.test.mjs (+9 к существующей проверке), scripts/mutation-gate.mjs (+3 мутанта), demo/smoke_smooth_zoom.mjs (+84). Спека docs/specs/082-smooth-zoom.md: только §10 и §13 (2 hunk'а, сверено git diff --stat/grep "^@@"). Changelog en/ru в том же коммите 4c057fa0 (User-Visible: yes).

Как проверялось

Гейт Статус Результат
npx tsc --noEmit прогнал чисто, exit 0
npm test прогнал 1650 pass / 0 fail / 1 skip (не связан с #396)
npm run build + сверка бандлов прогнал git status после билда чист — dist/, custom_components/houseplan/frontend, манифест уже в актуальном состоянии
node scripts/check-docs.mjs прогнал (diff трогает src/**) «Documentation checks passed (7 files, 10 external links)»
npm run golden:verify прогнал (диф касается glow/рендера) все кейсы passed, exit 0
Мутанты camera-cancel-loses-zoom, camera-anchor-from-presented, glow-feather-thaws-during-camera (node scripts/mutation-gate.mjs --id=…) прогнал по одному все три «тест покраснел, как обязан» (1/1 каждый)
node demo/smoke_smooth_zoom.mjs прогнал OK; interruptedShown==interruptedStored, structuralSaves=0, anchorDriftPx=0
node scripts/smoke-select.mjs --base origin/dev --head HEAD прогнал 11 «прямых совпадений» — прогнал все 11 (список ниже)
npm run invariants не прогонял diff не трогает геометрию комнат/стен/layout/marker.space/open_spans — только камера и glow-feather гейт
python -m pytest tests_backend не прогонял diff не трогает custom_components/**/*.py
perf-профили (demo/performance/*) не прогонял AC5 не называет конкретный перф-профиль; закрыт иначе (см. AC5 ниже)

11 «прямых совпадений» smoke-select (по символам _cameraTransition, _cancelCameraTransition, _saveZoom, _panStart, _pinchStart), все прогнаны, все OK: smoke_smooth_zoom, smoke_long_press_gesture, smoke_canvas_frame, smoke_decor, smoke_editor_gestures, smoke_infinite_canvas, smoke_kiosk_pan_lock, smoke_kiosk, smoke_pan_any_zoom, smoke_warm_dialogs, smoke_zoom_out. Полную матрицу (208 смоков) не гонял — diff узкий, одномодульный, вне неё не задевает подсистем (пред-релизная обязанность, не гейт ревью).

Разбор по AC

AC1 (пользовательская отмена сохраняет показанное). _cancelCameraTransition(commitTarget=false, keepPresented=false) (src/houseplan-card.ts:1250) при keepPresented=true и активном переходе читает this._cameraTransition.presented?.zoom до cancel(), затем зовёт _saveZoom(). _saveZoom() читает this._zoom, который _applyCameraTransitionFrame (:1222) синхронно держит равным presented на каждом кадре — значит к моменту вызова он уже равен замороженному кадру, второй канал не нужен. Единственный вызывающий с keepPresented=true — _stagePointerDown (:6410), это и есть буквальный сценарий issue. Доказано и автотестом: смоук demo/smoke_smooth_zoom.mjs реальным DOM (колесо → pointerdown до конца перехода → interruptedShown === interruptedStored, interruptedFroze: true), и мутантом camera-cancel-loses-zoom (откат keepPresented на false красит смоук). Доказано автотестом, тест умеет падать.

AC2 (структурная отмена не пишет). Из 11 мест отмены + dispose() только _stagePointerDown передаёт keepPresented=true; прочитал каждое (:1280,1414,1564,2376,2758,4201,4229,6102,6219,6303,6381) — классификация таблицы ТЗ подтверждена построчно, ни одно структурное место не получило второй аргумент. _zoomAt (:6303, немедленный pinch) не тронут, оба его вызывающих контекста (:6540, :6920) сами зовут _saveZoom() сразу после — поведение как до фикса. Смоук: structuralSaves === 0 после card._cancelCameraTransition(false) на середине перехода. Мутант camera-cancel-loses-zoom также покрывает обратную сторону (без него пользовательская ветка становится структурной — тест красится). Доказано автотестом + построчной сверкой кода.

AC3 (якорь точен на серии из шести нотчей, 8/16/33 мс). _cameraTargetAt(..., animated) (:6275) при animated=true и активном переходе берёт anchorFrom = this._cameraTransition.target — ту же цель, от которой в _onWheel (:6320) накапливается baseZoom. Юнит (test/viewport-transition.test.mjs:179) гоняет серию из 6 нотчей на 8/16/33 мс через реальный CameraTransitionController с фейковым клоком и проверяет дрейф мировой точки < 1e-9; тот же тест отдельно доказывает, что версия «от отстающего кадра» дрейфует >1 — то есть baseline дефекта воспроизведён, а не выдуман. Смоук подтверждает то же через реальный DOM: anchorDriftPx: 0. Мутант camera-anchor-from-presented (откат на false && …) красит и юнит, и смоук. Доказано автотестом, тест умеет падать (сам ассерт на дефект — прямое доказательство).

AC4 (накопление zoom не тронуто). Тот же юнит-файл, тест «AC4»: для обеих веток (anchorFromTarget: true/false) итоговый zoom после 6 нотчей равен 1.15**6 с точностью 1e-12 — фикс якоря математику не меняет, что и требовалось. baseZoom = this._cameraTransition.target?.zoom ?? this._zoom в _onWheel не редактировался этим диффом (только добавлен true в вызов _cameraTargetAt). Доказано автотестом.

AC5 (feather заморожен во время анимированного перехода). cameraStill = !this._pinchStart && !this._panStart && !this._cameraTransition.active (:10869-10871), передаётся в resolveGlowFeather. Прочитан весь путь: _cameraTransition.active — геттер контроллера, true строго на время phase==='running', false на settling/null — то есть ровно на кадрах анимированного перехода предикат cameraStill даёт false, feather не пересобирается; в момент _settleCameraTransition active уже false, предикат совпадает с допереходным поведением — визуальный результат в покое не меняется (подтверждено ниже: imageSha256 в docs/images/screenshots.json не изменился, поменялся только sourceSha256). Прямого счётчика вызовов за время анимации в тестах нет — вместо этого test/golden-matrix.test.mjs регэксом закрепляет и сам предикат, и место его использования, и мутант glow-feather-thaws-during-camera (откат на && true) красит этот тест. Проверено чтением кода + автотест на форму предиката (не на счётчик вызовов за прогон — план автотестов ТЗ называл отдельный perf-контракт demo/performance/*, который не добавлен; см. «Не в скоупе гейтов» ниже). AC по существу закрыт, но доказательство слабее, чем обещал план — это не блокирует: source-guard плюс мутант ловят именно тот регресс, который описывает AC5 (два флага жеста вместо признака движения камеры).

AC6 (§10/§13 правлены точечно). git diff origin/dev...HEAD -- docs/specs/082-smooth-zoom.md даёт ровно 2 hunk'а (169 и 224 строка исходного файла) — §10 п.3 и порог точности, §13 список видов отмены. Остальной текст спеки не тронут. Текст обоих правленных мест прочитан — соответствует контракту из ТЗ #396 дословно (anchor из целевого viewport, 1e-9 вместо «≤0.5px»; два вида отмены с перечнем мест). Проверено чтением, диф точечный.

AC7 (регресс не возвращается). _startCameraTransition и CameraTransitionController.start() этим диффом не редактировались (только добавлен параметр animated дальше по цепочке в _cameraTargetAt/_onWheel/ _stepZoom) — путь duration<=0 → phase='settling' без RAF цел. dispose() (:2758) по-прежнему зовёт this._cameraTransition.cancel(false) напрямую, RAF снимается в cancel(). npm test (1650/1650) включает существующие тесты на reduced-motion/RAF/config-leak — все зелёные, регрессии нет. Проверено чтением + полный набор существующих юнитов зелёный.

Что проверено и корректно

  • Классификация 11 мест отмены + dispose() из ТЗ (ревизия 3, принята зелёным SPEC-REVIEW-396-r3) совпадает с кодом один-в-один — сверено построчно, не только по номерам, но и по имени функции на каждой строке.
  • Единый источник числа: бейдж ${Math.round(this._zoom * 100)}% (:11571) и _saveZoom() читают одно и то же поле this._zoom — второго канала для показанного/сохранённого масштаба нет, test/single-source-numbers.test.mjs проходит (механическая часть контракта), смысловая проверена явно здесь.
  • Changelog ru/en обновлены в том же коммите 4c057fa0, где User-Visible: yes; трейлеры Issue:/User-Visible: на обоих коммитах диапазона корректны. Билд-коммит c1b6a01b идёт User-Visible: no — верно, это только синхронизация бандлов и отпечатка скриншотов.
  • docs/images/screenshots.json: изменился только sourceSha256 (отпечаток по src/** неизбежно съезжает), imageSha256 во всех записях не тронут — визуал в статике действительно идентичен, check-docs.mjs и golden:verify это подтверждают независимо.
  • Риски из ТЗ («двойная запись», «грань пользовательская/структурная», «якорь трогает pinch») все адресованы: _zoomAt не редактировался и сохранил старое поведение, идемпотентность записи не проверялась отдельным счётчиком двойной записи, но смоук считает saveCount и не показывает лишних записей на пользовательской ветке.

Чего не проверял и почему

  • npm run invariants — diff не касается рёбер комнат, записей толщины, layout, marker.space, open_spans; только камера/zoom/glow-feather.
  • python -m pytest tests_backend — ни один .py-файл не в диффе.
  • Полная матрица браузерных смоков (208 шт.) — прогнал только 11 «прямых совпадений» из smoke-select; «зарегистрированных связей» и «неопределённостей» инструмент для этого диффа не напечатал (список выше — полный вывод, ничего не отфильтровано).
  • Отдельный perf-профиль на число пересборок feather за один анимированный зум (план автотестов ТЗ, п.7) — не создан автором; AC5 закрыт иначе (см. разбор выше). Это расхождение с планом автотестов, а не с AC — не поднимаю как находку уровня Low/Medium, поскольку фактическое покрытие (source-guard + мутант, оба проверены на способность падать) отвечает на тот же вопрос, что должен был отвечать перф-профиль.
  • test/camera-persistence.test.mjs, названный в плане автотестов ТЗ, не создан — вместо него AC1/AC2 доказаны браузерным смоуком на реальном HouseplanCard (сильнее, чем юнит на голом контроллере, который план и предполагал). Не поднимаю как находку по той же причине.
  • docs/DEVELOPMENT.md не тронут: различение «пользовательская/структурная отмена» нигде не записано как готча за пределами кода/спеки. Прецедента для камеры/#82 в этом файле тоже нет (проверено — файл вообще не упоминает camera/zoom/transition), так что это не отклонение от практики подсистемы. Low, waived: причина в тексте выше, комментарий в коде (:1241) и §13 спеки выполняют ту же функцию для следующего разработчика.

Находки

Нет High. Нет Medium (ни в скоупе, ни вне скоупа). Два Low выше — по существу waived с объяснением, не требуют возврата автору.

Вывод

Все 7 AC закрыты: пять — автотестом с проверенной способностью падать (мутанты camera-cancel-loses-zoom, camera-anchor-from-presented, glow-feather-thaws-during-camera все reddened; юниты и смоук воспроизводят и baseline-дефект, и его фикс), два — чтением кода при неизменном покрывающем тестовом наборе. Полный локальный гейт (tsc, 1650 юнитов, build+bundle sync, check-docs, golden:verify, целевые смоуки) зелёный на SHA c1b6a01b. Спека #82 приведена в соответствие точечно, changelog и трейлеры на месте. Возврата в разработку не требуется.