diff --git a/docs/reviews/CODE-REVIEW-396-r2.md b/docs/reviews/CODE-REVIEW-396-r2.md new file mode 100644 index 00000000..90d2975b --- /dev/null +++ b/docs/reviews/CODE-REVIEW-396-r2.md @@ -0,0 +1,231 @@ +# 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) закрыты корректно, регресс не возвращается, +визуал в покое не затронут.