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

209 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 и трейлеры на месте.
Возврата в разработку не требуется.