Files
houseplan-card/docs/reviews/CODE-REVIEW-521-r1.md
T
2026-09-10 20:05:44 +00:00

21 KiB
Raw Blame History

CODE-REVIEW-521-r1

Issue: #521 «fix: alignment guides follow the live gesture again» Материал ревью: 0715321fdc69a2955d7850f3f384c1a0fdc25311 (ветка issue/521-align-guides-live, HEAD == origin/dev + 2 коммита: 53585b45 продукт, 0715321f отпечаток скриншотов). Заход: r1 · блокирующих циклов израсходовано 0 из 4.

Скоуп

Регресс #451 (перевод жестов редактора на «живой» путь) убрал направляющие выравнивания из трёх жестов десктопных редакторов (значок в devices, фигура в decor, курсор в plan). Задача восстанавливает слой направляющих в живом пути и чинит вторую поломку — _alignPoint в режиме devices читал замороженный _pos вместо живого _livePos. Класс A, полный трек (владелец назвал нарушенный критерий §5 — влияние на touch/перф-контракт кадра жеста). Диф продукта: src/live-editor.ts (+19/−4), src/houseplan-card.ts (+4/−3). Плюс: 4 новых мутанта (scripts/mutation-gate.mjs), тест-линт свидетеля (test/smoke-harness-contract.test.mjs), полностью переписанный demo/smoke_align_guides.mjs, оба changelog, пересобранный бандл, второй коммит — обновление отпечатка скриншотов документации (docs:accept --identical, картинки побайтово не изменились).

Как проверялось

Ревью прочитан по коду и прогнан руками — часть независимо от заявлений автора, часть с воспроизведением находок. Дешёвые гейты (tsc, test, build+сверка бандла) уже зелёные на этом SHA в Validate (https://github.com/Matysh/houseplan-card/actions/runs/34520976425) — не перегонял их ради самого факта зелени, но npm test и локальную сборку всё же выполнил повторно, поскольку без свежего demo/srv/assets/houseplan-card.js (не коммитится, #255) браузерные смоки было не запустить.

Гейт Прогнал Результат
npx tsc --noEmit да (часть npm run build) чисто
npm test да 2515 pass / 0 fail / 1 skip
npm run build + сверка dist / custom_components/.../frontend / demo/srv/assets да побайтово идентичны рабочему дереву, git status чист после сборки
node scripts/no-new-any.mjs --base origin/dev --head HEAD да новых any нет (25 добавленных строк в 2 файлах)
node scripts/check-docs.mjs да зелёный (диф трогает src/**)
node scripts/smoke-select.mjs --base origin/dev --head HEAD да 11 прямых совпадений (_pos, _livePos, _editorRuntime, _editorRuntimeOrThrow) + 22 слабые связи
Прямые смоки (align_guides, cover_no_plate, cover_plate_precedence, device_position_history, drag_bounds, edge_cases, glow, grid_snap, light_badges, modes, v8_draft_write) да, все 11 зелёные
Точечно из «слабых»/AC-рисков (editor_gestures, decor, merge_split) да зелёные
node scripts/mutation-gate.mjs --check (применимость патчей) да все мутанты, включая 4 новых задачи, применимы
Мутанты задачи по одному (live-editor-devices-drops-align-guides, live-editor-decor-drops-align-guides, live-editor-plan-drops-align-guides, align-point-reads-frozen-snapshot) да, все 4, полный прогон через scripts/mutation-gate.mjs --id=… (git worktree, пересборка, смок) все 4 «поймано 1 из 1» — свидетель действительно краснеет
node --test test/smoke-harness-contract.test.mjs да 7/7, включая новый тест-линт #521
AC9: npm run benchmark:large-house-interaction (7 образцов) + benchmark:compare против budgets-large-house-interaction.json да, самостоятельно — база собрана в отдельном worktree на fa7ac02c (= origin/dev, тот же SHA, что назвал автор) все метрики ✅, в частности timing.interactionSeriesMs.median 3175.4/3300, timing.editorSeriesMs.median 612.1/750, longTask.maxSingleMs 1184/1618.5 — без регрессии к базе
Собственный эксперимент: пришить обратно старое условие (makeTransparent только в ветке plan, т.е. без правки контракта №3) в изолированный git worktree, пересобрать, прогнать сам demo/smoke_align_guides.mjs да смок остался зелёным — см. находку Medium-1
golden, pytest tests_backend, полный набор смоков нет не требуются диффом (визуал не менялся кроме отпечатка, бэкенд не тронут); полный набор — предрелизный гейт

Не прогонял намеренно: golden:verify (диф не меняет пиксели вне подтверждённого identical-принятия), pytest tests_backend (Python не тронут), полную матрицу смоков (239 штук — не требуется диффом), WSL/HA-харнесс.

Находки

Medium-1 (в скоупе) — AC5 заявляет доказательство, которого смок не даёт

AC5: «Во время жеста в DOM ровно одна группа .alignguides»; «чем краснеет»: «снятие подавления осевого слоя даёт 2 — смок красный» (docs/reviews таблица AC, тело issue).

Это не так. Правка вынесла makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)') (src/live-editor.ts:358) из ветки if (host._mode === 'plan') в безусловное начало paintHouseplanEditor — то есть теперь осевая копия слоя гасится в любом режиме редактора, а раньше гасилась только в plan. Это разумная защита ровно от риска, названного в самом ТЗ («Двойной слой… если во время жеста случится осевая отрисовка»). Но проверил экспериментально: в отдельном git worktree (не в материале ревью) вернул эту строку внутрь if (host._mode === 'plan') { … }, как было до задачи, пересобрал бандл и прогнал сам переписанный demo/smoke_align_guides.mjs — все 26 полей вывода, включая devSingleGuideLayer, decorSingleGuideLayer, planSingleGuideLayer, остались true, смок напечатал OK.

Причина: _renderAlignGuides() в осевшей сцене (houseplan-card.ts:11698-11699) рисует .alignguides только когда _alignPoint не null; на момент последней осевой отрисовки перед стартом жеста (pointerdown, до которого _deviceDrag.moved/_decorDraft/_cursorPt ещё не в «жестовом» состоянии) эта группа пуста, и других осевых перерисовок в ходе самого смока не происходит — весь жест идёт по живому пути. Значит замороженная копия все время остаётся пустой независимо от того, подавлена она opacity:0 или нет, и groups() === 1 истинно в обоих случаях. Сценарий, который правка реально защищает (осевая отрисовка посреди жеста — приход hass, ступенька _hdrH, resize), смок не воспроизводит: он не форсирует ни одного стороннего requestUpdate() во время движений.

Проверил дальше (форсированный пробник в том же выброшенном worktree, не в материале): если во время шагов перетаскивания значка искусственно вызвать c.requestUpdate('_holdFired') (несвязанное реактивное свойство, эмулирует внешний settled-рендер), эксперимент завис на превышении времени Playwright — то есть даже принудительно вызвать вторую осевую отрисовку внутри активного жеста в текущей демо-обвязке не тривиально; я не довёл этот путь до результата и не настаиваю на нём как на предмете находки. Находка не в том, что защита не работает, а в том, что заявленное в AC5 доказательство ложно: третий столбец «чем краснеет» называет мутацию, которая, как показано выше, смок не ловит. Это ровно тот случай, о котором PROCESS.md §2.7 предупреждает отдельно: «пустой третий столбец — находка Medium»; здесь столбец не пуст, но недостоверен, что не лучше.

Почему Medium, а не High: сама защита в продукте существует и концептуально верна (единственный явный визуальный риск — задвоенная линия у пользователя, а не потеря данных/некорректная запись), просто не имеет свидетеля, который её удержит. Регрессия по этой строке не сломает ни один существующий тест.

Как чинить в скоупе задачи (предложение, не обязывающее автора): заставить смок форсировать осевую отрисовку в момент активного жеста — например, c.requestUpdate('_someUnrelatedSettledProp') (свойство вне liveProperties/hoverProperties/gestureProperties) на одном шаге каждого из трёх сценариев, с проверкой groups() === 1 сразу после, — либо честно понизить формулировку AC5 до «проверено чтением», раз механизм осознанно защищает окно, которое смок не воспроизводит. Второе дешевле; выбор — за автором.

Наблюдение (не находка, для полноты) — AC6 тоже без мутанта

Как и AC5, AC6 («один расчёт кандидатов на кадр») не имеет отдельной записи в scripts/mutation-gate.mjs, только проверку внутри самого смока (candidatesAreComputedOncePerFrame). В отличие от AC5, логика проверки (candidateCalls <= paints, где пять pointermove шлются без ожидания между собой) структурно способна поймать регрессию «расчёт на событие вместо кадра» — каждое лишнее срабатывание _alignCandidates() вне кадра подняло бы candidateCalls выше paints. Не воспроизводил такую мутацию отдельно (не нашёл дешёвого точечного способа сместить расчёт в обработчик события без более широкой правки); оставляю как «проверено чтением», не как находку.

Что проверено и корректно

  • AC1–AC4 (направляющая в трёх живых жестах). Проверено смоком плюс четырьмя мутантами — каждый мутант независимо пересобран и прогнан, каждый дал красный смок («поймано 1 из 1»). Свидетель ведёт настоящие PointerEvent/click, ждёт тишины _hdrH перед жестом, считает осевые циклы за движения (обязаны быть 0) — воспроизвёл сам, наблюдения совпадают с заявленными в issue числами (8 шагов сетки расхождения _pos/_livePos).
  • AC2 (живая точка выравнивания). _alignPoint в режиме devices читает _livePos(d) (houseplan-card.ts:12896); _livePos читает this._layout напрямую, минуя снимок _renderDeviceSnapshot, который использует _pos. Мутант align-point-reads-frozen-snapshot подтверждает регрессию при откате к _pos.
  • AC5 (частично) — сама защита существует в коде и корректно расширена на все три режима; не хватает свидетеля (Medium-1 выше).
  • AC6 — структура рендера гарантирует один расчёт на кадр: editorTemplate вызывается один раз за paintHouseplanEditor, который сам собран в один вызов на requestAnimationFrame через scheduleHouseplanEditor (гейт state.raf не даёт повторной постановки в очередь). Проверено чтением (src/live-editor.ts:344-391) и смоком (candidatesAreComputedOncePerFrame = true при собственном прогоне).
  • AC7 (старые гарантии: нет гидов в Просмотре, нет гидов без совпадения, #400-исключение перетаскиваемого маркера). Все три сохранены и переведены на настоящий жест без прежней подмены состояния; проверено собственным прогоном смока (noneInView, devNoGuideOffAxis, devCandidatesExcludeTheDraggedMarker, devGuideAnchorIsTheOtherMarker).
  • AC8 (свидетель не фабрикует состояние жеста). test/smoke-harness-contract.test.mjs регекспом проверяет отсутствие _deviceDrag =/_decorDraft = в demo/smoke_align_guides.mjs — прогнал node --test на этом файле, 7/7, включая новый тест; вручную проверил grep — присваиваний в файле действительно нет, только чтение (!!c._deviceDrag и т. п.).
  • AC9 (перф). Перепрогнал независимо от автора: своя база на fa7ac02c (голова origin/dev, тот же SHA, что назвал автор) в отдельном git worktree, свой кандидат на материале ревью, benchmark:compare — весь отчёт зелёный, ни одна строка не читает красным. Числа близки к тем, что привёл автор в хендоффе (расхождение в пределах шума прогона).
  • _renderAlignGuides стал «мягким» (this._editorRuntime?._renderAlignGuides() ?? nothing, было _editorRuntimeOrThrow()). Проверено чтением: _editing (единственный вызывающий контекст осевого пути) истинен только когда _mode уже plan/devices/decor, а вход в эти режимы уже требует загруженного _editorRuntime — так что смягчение не маскирует реальную ошибку на осевом пути, а покрывает только окно живого жеста, начавшегося до полной загрузки рантайма (контракт, пункт 5).
  • Трейлеры и changelog. Оба коммита несут Issue: #521 и корректный User-Visible; User-Visible: yes коммит (53585b45) правит оба changelog в этом же коммите (проверено git show --stat).
  • Отпечаток скриншотов (0715321f) — принят через docs:accept --identical, все 11 кадров побайтово совпали (imageSha256 не изменились), поэтому не требует ручной визуальной приёмки владельца; коммит только меняет sourceFingerprint/sourceSha256.
  • Бандл. Пересобрал с нуля из материала — dist/, custom_components/houseplan/frontend/, demo/srv/assets/ совпали с закоммиченными файлами побайтово (git status чист после сборки).
  • Класс изменений и трек. Класс A по прямому указанию владельца (комментарий в issue), полный трек с названным нарушенным критерием §5 — соответствует ТЗ и его зелёному ревью r3.

Чего не проверял

  • npm run golden:verify — диф не меняет видимую геометрию/стили сцены за пределами уже принятого identical-скриншота; не увидел оснований гонять.
  • python -m pytest tests_backend — custom_components/**/*.py не затронут.
  • Полный набор из 239 смоков — не обосновано диффом (2 изменённых файла, 7 символов на изменённых строках); прогнал 11 прямых совпадений плюс три из широкого списка, относящихся к жестам редактора.
  • WSL/полный HA-харнесс — вне гейта код-ревью.
  • Не довёл до конца эксперимент с принудительным осевым рендером посреди живого жеста (см. Medium-1) — не нашёл дешёвого способа в текущей демо- обвязке и не стал тратить на это больше бюджета раунда; сама находка не зависит от результата этого эксперимента.

Вердикт

Единственная находка — Medium в скоупе задачи (недостоверное доказательство AC5, не сама защита). High-находок нет. По правилу PROCESS.md §2.7 это жёлтый вердикт с возвратом автору для правки в этой же задаче.

Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче

Документ: docs/reviews/CODE-REVIEW-521-r1.md


Материал раунда

  • Ветка: issue/521-align-guides-live, коммит 0715321fdc69 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: d8bf0a0fe057a7b6d2470eb288b88850c51d5a24
    git log --all --format='%H %T' | grep d8bf0a0fe057
    
  • Тело issue: 1e7f8d192f340e80e878aec0d6de383c982263254b408f8fc928f383130f5684
  • Вердикт конвейера: yellow · High 0