Files
houseplan-card/docs/reviews/CODE-REVIEW-396-r2.md
T
2026-08-30 23:00:18 +00:00

232 lines
22 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-r2
Issue: [#396](https://github.com/Matysh/houseplan-card/issues/396) — регресс камеры после #82:
прерванный зум не сохраняется (B1), точка под курсором уезжает при быстрой серии
нотчей (B2), glow-feather не заморожен на время перехода (M2).
Материал: SHA `f3620f2b` (`origin/issue/396-camera-transition-fixes`), рабочее
дерево чистое, `origin/dev` полностью содержится в ветке (`git merge-base
--is-ancestor origin/dev HEAD` → true).
Заход: r2 · блокирующих циклов израсходовано 0 из 4 (зелёный вердикт r1 цикла
не тратит, #227).
## Почему этот раунд разбирается полностью, а не по дельте
Раунд r1 (SHA `c1b6a01b`) получил **зелёный** вердикт: High 0 / Medium 0. Пока
ревью шло, `dev` продвинулся на 11 коммитов; конвейер сам отменил слияние
(«ветка изменилась после проверенного материала», #312) и вернул задачу в
`S6-in-progress` для ребейза — это не находка ревью, а механика §7.2. Автор
перебазировал ветку на `dev` (HEAD стал `f3620f2b`) и заявил, что продуктовый
код не изменился ни на строку: «дельта против отревьюженного `c1b6a01b` ровно
нулевая по `src/**`, `test/**`, `demo/**` и `scripts/**`».
По §2.10 ребейз на ушедший вперёд `dev` — один из явно перечисленных случаев,
где «разбор остаётся полным»: после ребейза это другой код, даже если diff по
существу пуст. Коммит `c1b6a01b` переписан рёбейзом и недостижим (`git
cat-file -t c1b6a01b` → `fatal: Not a valid object name`), так что построчная
сверка «что изменилось с r1» технически невозможна — единственный корректный
способ подтвердить заявление автора — независимо перечитать весь diff задачи
(`origin/dev...HEAD`) и прогнать гейты заново, что и сделано ниже.
Результат независимой проверки: заявление автора подтвердилось. Диф задачи
(`git diff --stat origin/dev...HEAD`) содержит правку одного продуктового
файла (`src/houseplan-card.ts`, 38 строк), тестов/мутантов/спеки — и класс D
(`dist/**`, `custom_components/.../frontend/**`) плюс документацию
(changelog, `docs/images/*`, `docs/reviews/*`). `src/viewport-transition.ts`
диф не тронул вовсе. Никакого нового продуктового изменения ребейз не внёс.
## Скоуп
- **B1**: `_cancelCameraTransition` получил второй аргумент `keepPresented`;
единственный вызывающий с `true` — `_stagePointerDown` (буквальный сценарий
issue). Различение «пользовательская/структурная» отмена также записано в
§13 спеки #82.
- **B2**: `_cameraTargetAt` получил флаг `animated`; для колеса и кнопок якорь
читается из цели перехода (`_cameraTransition.target`), а не из
представленного (отстающего) кадра. §10 спеки #82 приведён в соответствие.
- **M2**: гейт заморозки glow-feather переехал с `!_pinchStart && !_panStart`
на `cameraStill`, включающий `!_cameraTransition.active`.
Классы файлов в диффе: A (`src/houseplan-card.ts`), B (`test/**`,
`demo/smoke_smooth_zoom.mjs`, `scripts/mutation-gate.mjs`), C (changelog,
`docs/specs/082-smooth-zoom.md`, `docs/specs/396-*.md`, `docs/reviews/*`), D
(`dist/**`, `custom_components/.../frontend/**`).
## Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | чисто |
| Unit | `npm test` | 1654 pass / 0 fail / 1 skipped (1655 всего) |
| Build + сверка бандла | `npm run build` → `git status --short` пуст; `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | MATCH, дерево class D синхронизировано |
| Docs fingerprint | `node scripts/check-docs.mjs` (diff трогает `src/**`) | «Documentation checks passed (7 files, 10 external links)» |
| No new `any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | «Новых any нет» (33 добавленных строки в 1 файле) |
| Bundle budget | `npm run bundle:budget` | 285 416 / 300 000 Б gzip, headroom 14 584 Б (warning про общий запас — предсуществующий, issue #367, не относится к #396: диф добавил ~55 Б) |
| Смоки, прямое совпадение (11/11, `node scripts/smoke-select.mjs --base origin/dev --head HEAD`) | `node demo/smoke_{smooth_zoom,long_press_gesture,canvas_frame,decor,editor_gestures,kiosk_pan_lock,kiosk,pan_any_zoom,warm_dialogs,zoom_out,infinite_canvas}.mjs` | все 11 зелёные, exit 0 (см. ниже про `infinite_canvas`) |
| Golden (визуал в покое) | `npm run golden:verify` | 147/147 passed, 0 расхождений |
| process-gate (офлайн) | `node scripts/process-gate.mjs --range origin/dev..HEAD` | «гейт пройден, предупреждений 0», 10 коммитов |
| model-invariants | не запускался | diff не трогает геометрию/рёбра/`layout`/`marker.space`/`open_spans` — камера и рендер, не модель плана |
| `pytest tests_backend` | не запускался | diff не трогает `custom_components/**/*.py` |
| performance_smoke / полная матрица смоков | не запускались | предрелизный гейт (§8); задача не задевает всё дерево смоков, локально достаточно прямых совпадений |
### Смок `demo/smoke_infinite_canvas.mjs`
r1 отметил его как красный (`backendAcceptsFarPayload`) независимо от #396 (тот
же результат на чистом `origin/dev`, проверено автором переключением ветки). В
этой песочнице смок прошёл **зелёным** (все 22 проверки true, exit 0) — видимо,
окружение здесь предоставляет бэкенд, которого не было у автора. В любом
случае результат не блокирует: зелёный здесь, а расхождение с окружением
автора — не регрессия #396 и не относится к этой задаче.
## Находки
Нет находок ни High, ни Medium, ни Low.
Прочитан и лично проверен весь измененный продуктовый код:
- `_cancelCameraTransition(commitTarget, keepPresented)` (`src/houseplan-card.ts:1257`):
`presentedZoom` читается из `this._cameraTransition.presented?.zoom` **до**
вызова `cancel()`. Проверено чтением: `_applyCameraTransitionFrame` (частота
— каждый кадр RAF, включая первый синхронный кадр в `start()`) держит
`this._zoom === presented.zoom` в течение всего активного перехода, поэтому
`_saveZoom()`, вызванный сразу после `cancel(false)`, пишет ровно показанное
значение — переменная `presentedZoom` используется только как флаг
«был ли активный переход», сама она в запись не идёт, но это не ошибка:
`this._zoom` к моменту вызова уже равен тому же числу. Единственный
вызывающий с `keepPresented=true` — `_stagePointerDown:6433`; остальные 10
мест отмены плюс `dispose()` (`:2781`, минуя обёртку) сверены построчно —
ни один не передаёт `true` по ошибке.
- `_cameraTargetAt(..., animated)` (`:6298`): `animated=true` передаётся только
из `_onWheel` (`:6346`) и `_stepZoom` (`:6362`) — единственных двух путей,
где может существовать незавершённый tween и где §10 спеки требует якорь из
цели. `_zoomAt` (pinch, immediate, `:6325`) сначала зовёт
`_cancelCameraTransition(false)`, после чего `_cameraTransition.active` уже
`false` — `animated` там не передаётся и роли не играет, якорь берётся из
`_cameraState()`, как и раньше; численно pinch-путь не тронут (AC подтверждён
и юнитом `#396 AC4`, и не изменившимся `_zoomAt`).
- `cameraStill` (`:10894`): единственное место построения признака и
единственный вызывающий `resolveGlowFeather` — читается напрямую, дублей нет.
- Одно число — один источник: `this._zoom` — единственная переменная,
питающая и рендер (`${Math.round(this._zoom * 100)}%` в бейдже зума,
`:11597`), и персист (`_saveZoom` читает `this._zoom`, `:6394`). Показанное и
сохраняемое в этом диффе — буквально одна и та же переменная в один и тот же
момент, дублирования нет.
## AC — доказательство
- **AC1/AC2** (пользовательская отмена сохраняет показанный зум, структурная —
не пишет ничего): доказано юнитом на контроллер
(`test/viewport-transition.test.mjs`, тест «#396 AC1/AC2», проверяет, что
`cancel(false)` не зовёт `settled` и обнуляет `presented` — обязанность
решает вызывающий) **и** браузерным смоком на реальном DOM
(`demo/smoke_smooth_zoom.mjs`: `userCancelPersistsTheShownZoom`,
`structuralCancelPersistsNothing`) — прогнан лично, зелёный, оба булевых
флага `true`. Тест умеет падать: мутант `camera-cancel-loses-zoom`
(откат `_stagePointerDown` на `_cancelCameraTransition(false)` без
`keepPresented`) зарегистрирован в `scripts/mutation-gate.mjs` и по описанию
автора краснит именно этот смок; логика мутации прозрачна — без
`keepPresented=true` `presentedZoom` останется `undefined` и `_saveZoom()` не
вызовется, `userCancelPersistsTheShownZoom` станет `false`.
- **AC3/AC4** (якорь точен на серии из 6 нотчей, накопление zoom не тронуто):
юнит с фейковым клоком (`test/viewport-transition.test.mjs`, тест
«#396 AC3»/«#396 AC4») доказывает дрейф `<1e-9` для `anchorFromTarget: true`
и контрольную ветку `anchorFromTarget: false` (baseline дефекта), которая
честно даёт заметный увод (`>1` единица) — тест не декоративен, разница
видна прямо в его собственной сборке. Прогнан лично (`npm test`, в составе
1654 тестов). Браузерный смок `fastWheelKeepsTheAnchor: anchorDriftPx < 0.5`
— зелёный, `anchorDriftPx: 0` в диагностике.
- **AC5** (feather заморожен во время перехода): source-guard тест
(`test/golden-matrix.test.mjs`) пиннит и предикат, и место его
использования — регекспы требуют буквальный текст `const cameraStill = …
&& !this._cameraTransition.active;` и его прямую передачу в
`resolveGlowFeather`. Прочитан регекс и код рядом — совпадают. Отдельный
перф-профиль числа пересборок (упомянутый в плане автотестов ТЗ) не создан;
это расхождение с планом автотестов уже разобрано в r1 (принято как Low, не
поднимается заново: `golden:verify` зелёный подтверждает, что визуал в покое
идентичен, а source-guard теста достаточно, чтобы регресс предиката
краснил тест при возврате старого условия).
- **AC6** (правка §10/§13 спеки #82 точечная): прочитан diff
`docs/specs/082-smooth-zoom.md` — ровно два блока правок (якорь п.3 плюс
порог, различение отмены §13), остальной текст не тронут.
- **AC7** (регресс не возвращается: reduced-motion 0 rAF, `dispose()` снимает
rAF, состояние перехода не в конфиге): `src/viewport-transition.ts` этим
диффом не тронут вовсе — существующие юниты на reduced-motion/`dispose`
(часть тех же 1654 зелёных) остаются доказательством без изменений в коде,
который они покрывают.
## Что проверено и корректно
- Полный диф задачи (`origin/dev...HEAD`) прочитан целиком, а не только по
условному «пятну» дельты — ребейз потребовал этого по §2.10.
- Все 7 AC закрыты автотестом либо разобраны по коду с явной пометкой, тест
умеет падать там, где это утверждается (проверено логикой мутанта, не
только словами автора).
- Оба changelog (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`) правлены в том
же коммите (`8f485c0b`), что и код — трейлер `User-Visible: yes` на месте.
`git show --stat 8f485c0b` подтверждает оба файла в списке.
Ребейз-коммит `f3620f2b` (только class D + docs) несёт `User-Visible: no`,
что верно — поведение не меняет, только пересобранные артефакты.
- Трейлеры и ветка/имя проверены `process-gate.mjs` офлайн — 0 предупреждений.
- Визуал в покое не затронут: `golden:verify` 147/147, `check-docs.mjs`
зелёный (отпечаток скриншотов документации не разошёлся).
- Одно число — один источник: см. находки выше, дублирования зума
показ/запись нет.
- Не в скоупе (§ ТЗ) — pinch/pan persistence, длительности анимаций, kiosk
double-tap, формат `LS_ZOOM`, editor zoom: диф этих путей не касается,
подтверждено смоками `editor_gestures`, `kiosk`, `kiosk_pan_lock`,
`pan_any_zoom` (все зелёные, прогнаны лично).
## Чего не проверял
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
- `node scripts/model-invariants.mjs` — diff не трогает геометрию/рёбра
комнат/толщину/`layout`/`marker.space`/`open_spans`; путь камеры и рендера
их не задевает.
- Полная матрица браузерных смоков и `performance_smoke` — предрелizный гейт
(§8); задача узкая и одномодульная, прямые совпадения покрывают
все затронутые пути (11/11 зелёных).
- Отдельный perf-профиль AC5 (число пересборок feather) — не создан автором;
принято в r1 как Low без переоткрытия (см. AC5 выше).
- `smoke_infinite_canvas.mjs` в окружении автора vs здесь: расхождение
зафиксировано, не расследовалось глубже — не относится к #396 в любом случае.
## Закрытие раунда r1
Раунд r1 (SHA `c1b6a01b`) вышел **зелёным**: High 0 / Medium 0 — находок для
закрытия нет. Единственное событие между r1 и r2 — не ревью-находка, а
механика конвейера: слияние отменилось, потому что `dev` продвинулся на 11
коммитов за время ревью (§7.2, «Слияние отменено: ветка изменилась после
проверенного материала (#312)»), задача вернулась в `S6-in-progress`, автор
перебазировал ветку и вернул `S7-code-review`. Бюджет циклов эта пара
«зелёный → ребейз» не тратит (§227): «блокирующих циклов 0/4» и в r1, и в r2.
| Событие r1→r2 | Чем закрыто | Где видно |
|---|---|---|
| Слияние r1 отменено (dev ушёл вперёд) | Ребейз ветки на `dev` | комментарий «Ребейз на ушедший вперёд dev выполнен, HEAD `f3620f2b`» + `git merge-base --is-ancestor origin/dev HEAD` → true |
| Риск «ребейз незаметно поменял продуктовый код» | Диф `origin/dev...HEAD` перечитан целиком в этом раунде; `src/houseplan-card.ts` — те же 38 строк, что и на `c1b6a01b` по описанию автора | `git diff --stat origin/dev...HEAD` в этом документе; `src/viewport-transition.ts` вне диффа |
| Осиротевшие чанки после первой пересборки (`de-CRMfGGHY.js`, `fr-0kHbASBE.js`) | Дерево пересобрано с нуля, `bundle-tree-committed` тест это поймал | комментарий автора «Отдельно: при первой пересборке… деревья пересобраны с нуля, теперь 7 файлов»; в этом раунде `npm run build` → `git status --short` пуст, 7 файлов в обоих деревьях (сверено) |
## Унаследовано из r1 — не сработало: раунд разобран полностью
По §2.10 ребейз на ушедший вперёд `dev` — явно перечисленный случай, где
«разбор остаётся полным», а не по дельте. Поэтому раздел «унаследовано без
повторной проверки» пуст по построению: весь диф задачи (`origin/dev...HEAD`),
все 7 AC и все гейты в таблице выше перепроверены в этом раунде заново, а не
приняты на слово из `docs/reviews/CODE-REVIEW-396-r1.md`. Единственное, что
формально не переисполнялось, — сами измерения B1/B2 из аналитики и
исполнения r1 (14,5/16,1/10,3 px дрейфа, 0 вызовов `settled` при обрыве) не
повторялись байт-в-байт, но их следствие — сегодняшние зелёные AC3/AC1 —
проверено заново собственным прогоном юнитов и смоков на текущем SHA, а не
переписано из прошлого документа.
## Вывод
Диф r1→r2 по продуктовому коду пуст; ребейз чист, слияние с `dev` не потеряло
и не задвоило ни одного пункта changelog (заявление автора о «конфликтовали
только оба ченджлога, слиты объединением, ни один пункт не потерян» проверено
чтением обоих файлов — прежний пункт про #32 на месте, пункт #396 добавлен
рядом). Полный независимый прогон гейтов и AC подтверждает вердикт r1: три
находки аудита (B1, B2, M2) закрыты корректно, регресс не возвращается,
визуал в покое не затронут.