Files
houseplan-card/docs/reviews/CODE-REVIEW-460-r1.md
T
2026-09-05 09:26:21 +00:00

98 lines
20 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-460-r1
Issue: [#460](https://github.com/Matysh/houseplan-card/issues/460) — «Смок мебели краснеет через раз: у живого пути #451 нет точки синхронизации, аналогичной `updateComplete`».
Этап: code (PROCESS.md §2.7). Заход r1. Блокирующих циклов израсходовано: 0/4.
Материал: `git log --oneline origin/dev..HEAD` и `git diff origin/dev...HEAD` на SHA `dfb4efaa1cb4695098a93e7472b7f64aab69fdc3`, дерево `37f08510521440b1ac1a1d4e2a5f7a1226c5a344`, ветка `issue/460-live-editor-settle` (detached HEAD).
Это первый заход код-ревью для #460 — предыдущего код-ревью нет, разбор полный. Разбор по дельте (§2.9/§2.10) относится к раунду r2+ этого же этапа и здесь не применяется.
## Скоуп проверки
Пять коммитов: `ca2a0023` (ТЗ), `570da3dd` (документ спек-ревью), `5763c6ae` (fix, `User-Visible: yes`), `5d09e722` (test), `dfb4efaa` (docs: отпечаток скриншотов). Продуктовый код — только `src/live-editor.ts` и `src/houseplan-editor-runtime.ts`; остальное — тесты, три смока-потребителя, мутанты, docs/changelog и сгенерированные бандл-деревья.
Проверялось:
- соответствие AC1…AC4 (`docs/specs/460-live-editor-settlement.md`) фактическому диффу и его исполняемое доказательство, а не заявление автора;
- корректность модели ревизий/waiters в `live-editor.ts` (coalescing, гонки, устаревшие обещания);
- что terminal-null для hover-preview действительно уходит в полный Lit-commit, а не в live-путь (AC2), и что это доказано смоком, а не только unit-тестом;
- что оба мутанта AC4 реально ловятся своими гейтами (не «должны ловиться», а поймал сам);
- что `smoke_furniture.mjs` действительно даёт ≥10 подряд зелёных прогонов, а не «должен»;
- трейлеры коммитов (`Issue:`, `User-Visible:`) и синхронность правки обоих changelog с fix-коммитом;
- отсутствие расширения скоупа за пределы «внутренний контракт + terminal-null routing + smoke-потребители».
## Как проверялось
Прочитаны `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, `docs/specs/460-live-editor-settlement.md`, `docs/reviews/SPEC-REVIEW-460-r1.md` (материал предыдущего этапа, не этого раунда), `docs/FURNITURE.md` (граница preview), полностью `src/live-editor.ts`, диффы `src/houseplan-editor-runtime.ts`, `test/live-editor.test.mjs`, трёх смоков, `scripts/mutation-gate.mjs`, `scripts/smoke-links.mjs`, `scripts/bundle-budget.mjs`, `docs/DEVELOPMENT.md`, `docs/CHANGELOG.md`/`.ru.md`.
Сборка/тесты выполнялись мной на этом SHA (Chromium и зависимости уже установлены средой, `npm ci` не запускал):
- `npx tsc --noEmit` — чисто, без вывода.
- `npm test` — `tests 1990 / pass 1989 / fail 0 / skipped 1`, включая обе новые записи в `test/live-editor.test.mjs`.
- `node --test --test-name-pattern="live editor|browser smokes use the explicit" test/live-editor.test.mjs` — 2/2 ok (изолированный прогон новых тестов).
- `npm run build` (`tsc --noEmit && rollup -c`) — успешно.
- `node scripts/bundle-sync.mjs` — обе копии (`custom_components/houseplan/frontend`, `demo/srv/assets`) синхронизированы; `git status --porcelain` после сборки пуст — совпадает с закоммиченными деревьями трёх копий (dist, custom_components, demo/srv).
- `node scripts/bundle-budget.mjs` — `initial View: 297481 B` в полосе `298500±2000`, предупреждение про малый запас (2519 Б < 15000) — существующий долг #367, не новый, ceiling пересчитан автором с объяснением в комментарии.
- `node scripts/check-docs.mjs` — «Documentation checks passed» (diff трогает `src/**`, отпечаток уже обновлён коммитом `dfb4efaa`).
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — «Зарегистрированная связь (3)»: `smoke_furniture.mjs`, `smoke_decor.mjs`, `smoke_decor_text.mjs`, ровно те три, что названы в AC3; НЕОПРЕДЕЛЁННОСТЕЙ и «прямых совпадений» сверх них нет.
- `node demo/smoke_furniture.mjs`, `node demo/smoke_decor.mjs`, `node demo/smoke_decor_text.mjs` — все три `OK` (по одному целевому прогону для decor/decor_text, как требует план автотестов).
- `node demo/smoke_furniture.mjs` в цикле — **10/10** последовательных запусков `exit=0 / OK` (AC3: «не менее 10 последовательных запусков без падений»).
- `node scripts/mutation-gate.mjs --id=live-editor-settlement-runs-ahead-of-paint` — «поймано 1 из 1».
- `node scripts/mutation-gate.mjs --id=live-editor-smoke-falls-back-to-double-raf` — «поймано 1 из 1».
- Ручная регрессионная проверка AC2 (описана ниже) — временный откат правки routing и повторный `npm run build` + `bundle-sync` + `node demo/smoke_furniture.mjs`, дерево полностью восстановлено (`cp` из бэкапа, пересборка), `git status --porcelain` после восстановления пуст.
Не запускал: `npm run invariants` (диф не трогает геометрию/`layout`/`marker.space`/wall-ключи — только счётчики ревизий и RAF-роутинг), `python -m pytest tests_backend` (диф не трогает `custom_components/**/*.py`, только сгенерированные фронтенд-бандлы внутри `custom_components/houseplan/frontend`), `npm run golden:verify` (не-скоуп прямо исключает изменение геометрии/стилей/слоёв, а сам фикс адресует момент коммита кадра, а не то, что рисуется), полный `scripts/mutation-gate.mjs` без `--id` (предрелизный гейт §8, не гейт ревью; два адресных мутанта AC4 прогнаны прицельно), полную матрицу `demo/smoke_*.mjs` (диф не задевает ничего вне зарегистрированной связи по инструменту выбора).
## Находки
Ниже — одно наблюдение уровня Low, не блокирует.
| # | Наблюдение | Где | Severity |
|---|---|---|---|
| L1 | `scheduleHouseplanEditor` не увеличивает `state.raf`, если `typeof requestAnimationFrame !== 'function'` (ветка для сред без RAF). В этом случае `whenHouseplanEditorSettled` видит `!state.raf` и резолвится немедленно, даже если `requestedRevision > paintedRevision` — то есть контракт «Promise не завершается раньше покраски» формально нарушается в среде без RAF. Ветка не нова (существовала до этой задачи как no-op guard) и не достижима ни в одном реальном потребителе: браузерные смоки идут через настоящий Chromium (RAF всегда есть), а юнит-тесты сами подставляют `globalThis.requestAnimationFrame` перед вызовом `routeHouseplanEditorUpdate`/`scheduleHouseplanEditor`. AC1/AC2/AC3 её не задевают ни в одном сценарии. | `src/live-editor.ts:365-373` (`scheduleHouseplanEditor`), `src/live-editor.ts:377-383` (`whenHouseplanEditorSettled`) | Low |
Решение по L1: не блокирует и не требует правки в этой задаче — сценарий недостижим текущими потребителями (production-браузер и тесты с fake RAF). Оставляю как наблюдение на будущее, если когда-нибудь появится потребитель без RAF (SSR/headless без polyfill).
## Что проверено и признано корректным
- **AC1 (явная синхронизация)**: `whenHouseplanEditorSettled`/`finishRevision`/`requestedRevision`/`paintedRevision`/`waiters` в `src/live-editor.ts:14-104,364-383` реализуют монотонный счётчик ревизий с очередью waiter'ов. Прогнанный тест `live editor settlement waits for paint and releases on commit or dispose` (`test/live-editor.test.mjs:85-161`) с управляемым fake RAF доказывает: (а) промис не резолвится до реального вызова RAF-колбэка (`firstDone === false` до `runFrame()`); (б) две смежных смены `_cursorPt` перед одним RAF используют один и тот же кадр (`frames.size === 1`) и промис ждёт именно объединённую ревизию; (в) `commitHouseplanEditor()` завершает промис и отменяет RAF (`frames.size === 0` после commit); (г) `disposeHouseplanEditor()` делает то же самое. Я запускал этот тест изолированно — зелёный.
- **AC2 (очистка terminal hover)**: до правки `routeHouseplanEditorUpdate` маршрутизировал `_furnPreviewInput = null` (hover-свойство) в live-путь независимо от значения — значит очистка не проходила через `commitHouseplanEditor`, а значит не снимала уже нарисованный в основном Lit-слое preview. Новая строка `src/live-editor.ts:136-137` возвращает `false` для null-значений hover-свойств, отправляя изменение по обычному циклу Lit → `updated()` → `commitHouseplanEditor()` (`houseplan-card.ts:4009-4011`, вызывается безусловно на каждом `updated()`). Unit-тест (`test/live-editor.test.mjs:118-121`) это доказывает явной ассерцией на `routeHouseplanEditorUpdate(...) === false`. Браузерное доказательство — `pointerLeaveClearsPreview` в `smoke_furniture.mjs`, у меня зелёное. **Я сам временно откатил именно эту строку** (вернул старое поведение маршрутизации), пересобрал (`npm run build` + `bundle-sync`) и перезапустил `smoke_furniture.mjs`: `pointerLeaveClearsPreview` покраснел (`expected true, got false`), после чего я восстановил файл из резервной копии, пересобрал и синхронизировал бандл заново — `git status --porcelain` пуст, дерево идентично коммиту. Это подтверждает, что смок способен падать именно на этом дефекте, а не проходит «всегда зелёным» независимо от кода.
- **AC3 (browser-путь без временной лотереи)**: все три названных смока используют `const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;` вместо двойного RAF (диффы `demo/smoke_decor.mjs`, `demo/smoke_decor_text.mjs`, `demo/smoke_furniture.mjs`). Статический тест `live editor browser smokes use the explicit settlement contract` проверяет это регэкспом по исходнику всех трёх файлов. `_whenLiveEditorSettled` (`houseplan-editor-runtime.ts:1225-1230`) — единственный потребитель внутри карточки, наружу не экспортируется (соответствует принятому предположению «остаётся методом lazy editor runtime»). Подготовка мебельного смока (`demo/smoke_furniture.mjs:53-70`) ждёт `ResizeObserver` на `stageEl()`, затем крутит `while (c._modeTransitionBusy || c._refitRaf || c._pendingRefitSize) await new Promise(requestAnimationFrame)` — все три флага реально существуют в `houseplan-card.ts` (`_modeTransitionBusy` геттер, `_refitRaf`/`_pendingRefitSize` поля), не изобретены. Результат пишется в `out.initialModeSettled` и проверяется общим `checkAll(res)` в конце файла (не «вычислено и забыто»). Я прогнал `smoke_furniture.mjs` 10 раз подряд — `10/10 exit=0/OK`; `smoke_decor.mjs` и `smoke_decor_text.mjs` — по одному целевому прогону, оба `OK`, как и требует план автотестов.
- **AC4 (mutation-защита)**: оба названных в ТЗ мутанта (`live-editor-settlement-runs-ahead-of-paint`, `live-editor-smoke-falls-back-to-double-raf`) добавлены в `scripts/mutation-gate.mjs` с прицельными `guard`-командами. Я запустил каждый мутант через `node scripts/mutation-gate.mjs --id=<mutant>` — оба «поймано 1 из 1», то есть их `guard`-тест реально краснеет на патче и реально зеленеет на чистом коде.
- **Единственный источник числа**: диф не добавляет и не меняет ни одной пользовательски видимой величины (ширина/высота/подпись/подсветка) — только внутренние счётчики ревизий RAF и маршрутизацию null-присвоений. `test/single-source-numbers.test.mjs` в общем прогоне `npm test` зелёный, отдельного риска дублирования числа в этой задаче не вижу.
- **Синхронность релиз-артефактов**: коммит `5763c6ae` содержит одновременно фикс, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` и `docs/DEVELOPMENT.md` (новая заметка про то, что `updateComplete` не барьер для live-editor — закрывает наблюдение L2 из спек-ревью) — трейлер `User-Visible: yes` на месте, оба changelog правда в том же коммите, а не в последующем.
- **Трейлеры**: у всех пяти коммитов диапазона есть `Issue: #460`; `User-Visible: yes` только у fix-коммита, у остальных `no` — согласуется с их содержимым (доки/тесты/спека).
- **Бандл-бюджет**: пересчёт потолка `298 000 → 298 500` сопровождён комментарием с точными числами (297 496→297 503 Б) и причиной (новый content-hash ленивого чанка); я перепроверил актуальный gzip (`297 481 Б`) — попадает в полосу, предупреждение про малый запас — существующий, не новый долг (#367).
- **Три копии бандла синхронны**: пересборка + `bundle-sync` на моей машине не оставила diff — `custom_components/houseplan/frontend`, `dist`, `demo/srv/assets` содержимое коммита воспроизводится детерминированно.
- **Смок-выборка**: `scripts/smoke-select.mjs` называет ровно три смока из AC3 как «зарегистрированная связь», без «прямых совпадений» или «НЕОПРЕДЕЛЁННОСТЕЙ» сверх них — полная матрица `demo/smoke_*.mjs` не требуется.
- **Скоуп не расширен**: диф не трогает геометрию мебели, магнит, толщину, opacity, конфиг/историю Undo-Redo, публичный API карточки/HA, `live-viewport.ts`, touch/pen поведение — совпадает с «Не-скоуп» ТЗ.
## Чего не проверял
- `npm run invariants` — диф не задевает геометрию стен/`layout`/`marker.space`/`open_spans`; единственные тронутые структуры — счётчики ревизий и boolean-роутинг в live-editor, инварианты модели неприменимы.
- `python -m pytest tests_backend` — `custom_components/**/*.py` не менялся, только сгенерированные фронтенд-бандлы внутри той же директории.
- `npm run golden:verify` — не-скоуп ТЗ прямо исключает изменение геометрии/стилей/слоёв; фикс меняет момент фиксации кадра, а не его визуальное содержимое.
- Полный `node scripts/mutation-gate.mjs` без `--id` — предрелизный гейт (§8), не гейт код-ревью; два адресных мутанта AC4 прогнаны и проверены прицельно, остальные ~тысячи мутантов не относятся к этому диффу.
- Полная матрица `demo/smoke_*.mjs` (223 смока) — инструмент выбора называет только три относящихся, широкого совпадения (>44 смоков) нет.
- Заявленная в issue частота исходного дефекта (7 паданий из 12 на чистом `dev`) — не воспроизводил на `dev`, взял как контекст проблемы; вместо этого лично подтвердил детерминированность 10/10 на текущем SHA и способность смока падать на целевом дефекте (ручной откат AC2-правки).
- Perf-профили — не названы в AC, не затронуты чувствительные к перфу пути (диф не в hot-path рендера, только RAF-роутинг вокруг него).
## Вердикт
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/460-live-editor-settle`, коммит `dfb4efaa1cb4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `37f08510521440b1ac1a1d4e2a5f7a1226c5a344`
```
git log --all --format='%H %T' | grep 37f085105214
```
- ТЗ `docs/specs/460-live-editor-settlement.md`, блоб `1b215041caaeeb3b98292ace2517ef11339be5a2`
```
git log --all --find-object=1b215041caaeeb3b98292ace2517ef11339be5a2 -- docs/specs/460-live-editor-settlement.md
```