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

22 KiB
Raw Blame History

CODE-REVIEW-396-r2

Issue: #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) закрыты корректно, регресс не возвращается, визуал в покое не затронут.