22 KiB
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=truepresentedZoomостанется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:verify147/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) закрыты корректно, регресс не возвращается,
визуал в покое не затронут.