19 KiB
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 и трейлеры на месте.
Возврата в разработку не требуется.