mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
209 lines
19 KiB
Markdown
209 lines
19 KiB
Markdown
# 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 и трейлеры на месте.
|
||
Возврата в разработку не требуется.
|