docs: review document for #478

Issue: #478
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-06 15:31:44 +00:00
parent 1cadd520c0
commit b754d362ca
+142
View File
@@ -0,0 +1,142 @@
# CODE-REVIEW-478-r1
- **Issue:** #478 — отказ от сущности «черновик контура» (`room_drafts`), wall model v9 → v10
- **Ветка:** `issue/478-remove-room-drafts`
- **SHA материала:** `1cadd520c0658e14ec8fd1410241e55ddbac21e1` (сверено `git rev-parse HEAD` перед выводом итога)
- **Диапазон:** `git diff origin/dev...HEAD` — 154 файла, +3515/-4309
- **ТЗ:** `docs/specs/478-remove-room-drafts.md`, ревью ТЗ зелёное (`docs/reviews/SPEC-REVIEW-478-r1.md`, r1, 0 циклов)
- **Заход:** r1 · блокирующих циклов израсходовано **0 из 4** до этого вердикта
## Вердикт
**Красный.** Найдено High: 2 (обе — контракты миграции/защиты модели, заявленные AC1/AC2), Medium: 4 (все в скоупе задачи), Low: 2.
## Скоуп
Полный разбор (не по дельте — это r1). Задача убирает persisted `room_drafts`, поднимает модель до v10 в TS и Python, переносит каждый принятый клик цепочки сразу в `partitions`, и при замыкании комнаты атомарно поглощает совпадающие partitions (`reconcileCoincidentPartitions`). Это ровно тот случай, где "работает ли оно вообще" зависит от точного соответствия TS/Python логики миграции, атомарности поглощения и того, что golden/смоки реально что-то доказывают, а не молчат.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Дешёвые гейты | подтверждены зелёным Validate на этом SHA: https://github.com/Matysh/houseplan-card/actions/runs/34041344030 (jobs `frontend`, `backend`, `hacs`, `hassfest`, `process-gate` — success; `smoke`/`golden`/`performance_smoke` — **skipped**, обычный push не `heavy`) | не перегонял typecheck/test отдельно — доверяю CI на этом SHA |
| `npm run build` | напрямую | зелёный, 15.6s |
| `npm run bundle:sync` + `cmp` 3 копий | напрямую | побайтно совпадают (dist / custom_components/houseplan/frontend / demo/srv/assets) |
| `node scripts/check-docs.mjs` | напрямую (diff трогает `src/**`) | «Documentation checks passed (7 files, 12 external links)» |
| `node scripts/model-invariants.mjs` | на синтетическом large-house конфиге (`demo/fixtures/large-house.mjs`) и на мигрированном v9→v10 фикстуре (`test/fixtures/282-wall-identity-parity.json`, прогнан через `commitWallSegmentModel`) | «Инварианты выполнены» на обоих |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | напрямую | 75 прямых совпадений, 1 зарегистрированная связь (`smoke_optimize_coincident_partition` ← `reconcileCoincidentPartitions`), 37 слабых |
| Целевые смоки (17 из выборки: `room_autoclose`, `v8_draft_write`, `free_walls`, `unified_wall_tool`, `plan_snap_overlay`, `wall_face_overlap`, `active_chain_ink`, `wall_chain_thickness`, `wall_draw_click`, `wall_chain_merge`, `wall_junctions`, `zero_wall_migration_unblocked`, `edit_walk`, `merge_split`, `island_rooms`, `optimize_coincident_partition`, `plan_drawing_repairs`) | `node demo/smoke_<name>.mjs` после свежей сборки | все 17 зелёные |
| `npm run golden:verify` | напрямую | **145 passed / 16 different** — см. находки; проверено, что вызвано этим диффом только частично (см. ниже) |
| `python -m pytest tests_backend` | — | **не прогонял**: `.venv-backend` в этой среде нет, `python3 -c "import homeassistant"` падает `ModuleNotFoundError`. Доверяю зелёному backend-джобу CI, но независимая параллельная проверка миграции backend показала расхождение с TS (см. High-1) без запуска pytest — прямым исполнением обоих движков |
| Мутанты `wall-draw-full-preflight-again`, `current-rejected-physical-write-keeps-optimistic-wall` | `node scripts/mutation-gate.mjs --id=<id>` (оба смысла проверены отдельным агентом) | чистый прогон зелёный, с мутантом — красный на обоих |
| `npm run inventory` / полный `mutation-gate` | — | не прогонял: требует Python-окружение (часть guard'ов — `pytest`), которого здесь нет; это не мешает вердикту, т.к. два новых/переименованных мутанта проверены точечно |
### Что НЕ проверял и почему
- Полный `pytest tests_backend` — среда без `homeassistant`. Компенсировано прямым запуском Python-модуля миграции вне HA-обвязки.
- Полная матрица golden (161 сценарий) прогонялась целиком один раз; для 16 расхождений отдельно проверял, вызваны ли они этим диффом (см. ниже) — не по касательной, а сравнением с `origin/dev` на этом же дереве зависимостей.
- Performance-профили `large-house-*` не гонял отдельно (`benchmark_wall_draw_click` — только через мутационный гейт); полный performance smoke — предрелизный гейт.
## Golden: что вызвано этим диффом, а что нет
`golden:verify` в режиме `--mode=verify` не принимает `--scenario`, поэтому расхождения перепроверялись `--mode=capture --scenario=<id>` на HEAD и на чистом `origin/dev` (worktree, тот же `node_modules`, тот же Chromium — `package-lock.json` не менялся). Дважды столкнулся с нестабильностью самого харнесса (см. ниже) и учёл её отдельными повторными прогонами.
| Сценарий | Вердикт | Как проверено |
|---|---|---|
| `isometric-geometry-view-dark/light`, `isometric-live-layers-dark`, `isometric-touch-kiosk-dark`, `isometric-stage3-*` (5), `decor/general/device-ripple/space-room-color-popover-*` (4) | **Не вызвано этим диффом** — расхождение с эталоном идентично на `origin/dev` (совпадающий `actualSha256` в изолированном `--scenario`-прогоне на обеих ветках для проверенных представителей; для цветовых поповеров дополнительно подтверждена нестабильность — три изолированных прогона одного и того же дерева дают три разных sha при стабильном большом `diffRatio`, т.е. это существующий шум/дрейф эталона, не имеющий отношения к #478) | сравнение sha256 HEAD vs `origin/dev`, повторные прогоны на стабильность |
| `isometric-large-warm-remount-dark` | **Не вызвано этим диффом** — при изолированном `--scenario` прогоне HEAD и `origin/dev` дают идентичный sha; расхождение в самом первом полном прогоне было артефактом порядка сценариев в общем батче (переменное «тёплое» состояние демо-страницы), а не кода | то же сравнение, плюс контрольный повторный прогон полного батча не проводился (дорого) — вывод basируется на изолированном сравнении, что достаточно для причинности, но не исключает нестабильности самого батч-режима как отдельного явления |
| `wall-junctions-plan-preview-light` | **Вызвано этим диффом — реальная находка**, см. Medium-1 ниже | стабильный `diffRatio` (≈0.0009 на dev против ≈0.0024 на HEAD в нескольких повторах каждой стороны, при том что sha «шумит» из-за несвязанного дрожания пикселей в другом месте кадра) |
## Находки
### High-1 — TS и Python дают разные ID и разные решения accept/reject на одном и том же legacy JSON (нарушение AC1/§7.2)
Файлы: `src/wall-segment-model.ts:723-780` (`migrateRoomDraftsToPartitions`) vs `custom_components/houseplan/wall_segment_model.py:621-670` (`_migrate_room_drafts_to_partitions`). Проверено прямым запуском обоих движков на одинаковом JSON (компилированный `test-build/wall-segment-model.js` против `wall_segment_model.py` в обход HA-обвязки), не по описанию, а по факту выполнения:
1. **`cm: null` в сегменте черновика.** TS: `Number(segment?.cm)` → `Number(null) === 0`, конечно и в диапазоне → **принимается**, партиция с `cm: 0`. Python: `float(segment["cm"])` на `None` бросает `TypeError`, пойман общим `except` и переброшен как `zero-length` → **отклоняется**. Один и тот же документ мигрирует на фронтенде и убивает запись на бэкенде.
2. **`draft.id` как явный JSON `null` (не отсутствующий ключ).** TS: `String(draft?.id || '')` даёт `''` независимо от того, был ли ключ `null`, отсутствовал или был пуст. Python: `draft.get('id', '')` возвращает **дефолт только при отсутствующем ключе**; при `"id": null` возвращает `None`, и f-строка кладёt в соль буквальное `"None"`. Проверено: при отсутствующем `id` сгенерированные ID совпадают (`wall-ertogsjkok3uxzjjhuco` == `wall-ertogsjkok3uxzjjhuco`), при явном `"id": null` — расходятся (`wall-ertogsjkok3uxzjjhuco` vs `wall-657kjbohfjpfg6vyuvu3`). Ровно тот класс входа, который называет сам §15.1.3 ТЗ («pre-v8 draft без ID»).
3. **Допуск нулевой длины ребра.** TS зовёт `samePoint(a, b, eps = 0.001)` (`src/logic.ts:426`) — тот же helper, что используется по всему проекту как «действующая legacy fail-closed политика» для вырожденных отрезков. Python сравнивает `a == b` как списки — **без допуска вообще**. Проверено на `a=[0,0], b=[0.0005,0.0005]` (расстояние ≈0.0007, внутри допуска TS): TS кидает `zero-length`, Python принимает и сохраняет партицию длиной 0.0007 нормализованных единиц. Это отдельно неверно и относительно самого Python (не использует объявленный допуск), не только относительно TS.
**Чем краснеет:** прямой запуск (см. выше) — не тест из репозитория, а ручное исполнение обоих движков на трёх конкретных входах; воспроизводится детерминированно. Существующий тест с меткой AC1 (`test/wall-segment-model.test.mjs:58-89` и `tests_backend/test_wall_segment_model.py:379-401`) эти три входа не покрывает и для сгенерированного fallback ID проверяет только `startsWith('wall-')` — т.е. не способен поймать даже полное расхождение значений ID между языками, только его отсутствие.
**Почему High, а не Medium:** AC1 прямо требует «generated IDs совпадают в TS/Python» и «malformed input обрабатывается одинаково с действующей legacy политикой fail-closed» (§7.2 ТЗ) — это ядро обязательного условия отказа задачи (единая идентичность через миграцию), а не побочная деталь. Реальный legacy-экспорт с `cm: null` или `"id": null` в одном из полей (оба валидны JSON, оба правдоподобны как результат кривого экспорта/бэкапа) мигрирует по-разному в зависимости от того, какой движок обработал документ первым — ровно то, чему AC1/§7.2 посвящены целиком.
### High-2 — устаревшая v9-запись поверх уже мигрированного v10-документа не отклоняется (нарушение AC2/§6.2)
Файлы: `custom_components/houseplan/validation.py:175-216` (`validate_wall_model_transition`), `:111-131` (`_catalog_coupled_wall_geometry_projection`), `:1867-1881` (`_config_wall_segment_invariants`), `custom_components/houseplan/websocket_api.py` (обработчик `config/set`, строки ~1570-1610).
Сценарий: сохранённый документ — `model_version: 10`, без `room_drafts`. Устаревший v9-клиент, ничего не знающий о миграции, отправляет `config/set` с `model_version: 9` и восстановленным/локально закешированным `room_drafts`, при этом `rooms`/`walls`/`open_spans` не меняются (обычный «эхо» неизменённого контура — типичный случай для клиента, который просто открыл старую вкладку и сохранил несвязанную правку).
Трассировка:
- `_catalog_coupled_wall_geometry_projection` (validation.py:111-131) по своей собственной документации **намеренно не смотрит на `room_drafts`** («Drafts… carry their own ids… None of those independent objects requires a different contour wall_segments catalogue») — это верно для дизайна v8, но именно `room_drafts` теперь и есть поле, чьё возвращение как раз обязано быть отклонено (§6.2).
- Значит `contour_geometry_changed` (validation.py:183-186) — `False`.
- Единственная ветка, которая может бросить `WallModelClientOutdatedError` для `8 ≤ new_model ≤ old_model`, целиком под условием `contour_geometry_changed` (validation.py:199-215) — при `False` не выполняется вовсе.
- `_config_wall_segment_invariants`'ин room_drafts-чек (validation.py:1880-1881) смотрит на **заявленный отправителем** `model_version ≥ 10` — у честно устаревшего клиента он `9`, проверка не срабатывает.
- Обработчик `config/set` (websocket_api.py, ~1570-1610) вызывает только `validate_wall_model_transition` + `CONFIG_SCHEMA` + маркерные валидаторы — **не** вызывает `commit_wall_segment_model`/миграцию. (Миграция вызывается только в обработчике `houseplan/optimize`, websocket_api.py:1931 — другая команда, не обычное сохранение.)
Итог: документ с `model_version: 9` и восстановленным `room_drafts` **проходит и перезаписывает** уже мигрированный v10-документ — ровно то, что §6.2 запрещает текстом («Поле нельзя молча отбросить, частично принять или вернуть в current config», «backend отклоняет весь write существующей ошибкой `wall_model_client_outdated`»).
Косвенное подтверждение из самого диффа: `git diff origin/dev...HEAD -- tests_backend/test_wall_segment_model.py` показывает, что прежний тест `with_draft` (устаревшая запись, добавляющая новый `room_drafts`, ранее корректно **проходившая** `validate_wall_model_transition` без исключения) был просто переименован/заменён на `with_partition` с тем же исходом «проходит», а не переведён в утверждение об отказе. Единственный новый тест на `room_drafts`-инвариант (`test_wall_segment_model.py`, вокруг `validation.py:1880`) отправляет конфиг, сам заявляющий `model_version: 10`, — не реалистичный случай «хранится v10, прислан v9».
**Чем краснеет:** прочитано и прослежено по коду (`проверено чтением, не исполнением`) — конкретный вход построен явно (см. выше), путь трассирован до персиста без единого блокирующего условия. Не запускал pytest (среда без `homeassistant`), но трассировка не зависит от рантайма HA — это чистая проверка функций модуля `validation.py`.
### Medium-1 — golden-харнесс не переименован вслед за полем, из-за чего сценарий превью толщины цепочки реально сломан (в скоупе, `demo/golden/harness.mjs`)
`demo/golden/harness.mjs:1287,1289,1308` всё ещё пишет `card._activeDraftId = null` и `card._draftSegmentCms = [...cms]`. Этот диф переименовал соответствующие поля продукта в `_activeWallChainId`/`_wallChainSegmentCms` (проверено — ни одного `_activeDraftId`/`_draftSegmentCms` не осталось в `src/*.ts`), но эти два места в харнессе — тот же файл, который этот дифф уже правил в других местах (замена `room_drafts` → `partitions` в фикстурах) — остались нетронутыми.
Сценарий `wall-junctions-plan-preview-light` (matrix.mjs:422-427) использует `wallJunctionPreview: { cms: [12], cm: 24 }` — специально с разными значениями, чтобы отличить «превью показывает толщину сегмента» от «превью показывает толщину инструмента по умолчанию». Поскольку харнесс пишет в несуществующее более поле, рендерер (который реально читает `this.host._wallChainSegmentCms`, подтверждено чтением кода рендер-пути) получает `undefined` и откатывается на равномерную `24`, а не заявленные `12`.
**Чем краснеет:** эмпирически. `npm run golden:verify` на HEAD даёт для этого сценария `status: different`, стабильный `diffRatio ≈ 0.0024` (2517 px) в трёх повторных изолированных прогонах (sha «шумит» из-за несвязанного дрожания в другом месте кадра, но diffRatio стабилен). На `origin/dev` (тот же baseline, тот же Chromium, `--scenario`-прогон в чистом воркdереве) тот же сценарий даёт стабильный `diffRatio ≈ 0.0009` (933 px) в двух повторах. То есть дифф **увеличивает** расхождение с эталоном примерно втрое, воспроизводимо, и это расхождение никто не объяснил и не принял (`golden:verify` в CI на этом SHA не запускался — job `golden` был `skipped`, обычный push не `heavy`; в хендофф-комментарии автора `golden:verify` не упомянут вовсе).
Нарушает: §12.6/AC14 ТЗ («визуальный контракт намеренно не меняется… любое отличие требует отдельного объяснения»/«golden:verify» как обязательное доказательство) — расхождение есть, объяснения и `--reviewed`-приёмки нет.
### Medium-2 — путь поглощения комнаты не атомарен при вырожденном `_spaceModel()` (в скоупе, риск для AC9)
`src/houseplan-editor-runtime.ts:6856` (`_applyWallFaceBatch`) и `:7010` (`_commitRoom`) — новый (для 6856) и существующий (для 7010, оба зеркальны) паттерн: после того как `sp.rooms` уже мутирован (новая комната добавлена, `sp.walls` переприсвоен), код берёт свежий `_spaceModel()` для запуска `reconcileCoincidentPartitions` и на `if (!reconciledModel) return;` **выходит без вызова `_commitPhysicalGeometry(..., before)`** — то есть без коммита и без отката к `before`. `sp` — живая ссылка внутрь `this.host._serverCfg` (не копия), так что уже внесённая мутация остаётся в памяти неподтверждённой и неоткаченной.
Это расширение уже существовавшего на `origin/dev` паттерна (`if (!updatedModel) return;`, строка 6820, существовала и раньше как `:7273` в `origin/dev`) — но строка 6856 добавлена именно этим диффом для шага поглощения, то есть это новый повторный случай той же дыры, не унаследованный один-в-один. Триггер («когда `_spaceModel()` реально возвращает `null` в этой точке») не продемонстрирован ни тестом, ни ручным прогоном — оцениваю как маловероятный, но реальный пробел относительно буквального текста AC9 («Cancel, validation failure, save conflict и лимит оставляют исходные partitions и session chain без partial room»).
### Medium-3 — AC13 (фикс-пойнт Optimize сразу после accept) не доказан через реальный путь редактора
Тесты `test/optimize-hidden-obstacles.test.mjs:26-52` и релевантный кейс `test/coincident-partitions.test.mjs` проверяют `partitionsReconciled === 0` на **уже смигрированных/подготовленных** данных через прямой вызов `reconcileCoincidentPartitions`/`optimizePlans`, не через настоящий путь редактора (`_applyWallFaceBatch`/`_commitRoom`). Единственный смок, реально проходящий через путь редактора с `allowCoincidentPartitions: true` — `demo/smoke_unified_wall_tool.mjs` (`acceptConsumesCoincidentPartitions`) — проверяет отсутствие partitions сразу после accept, но **не вызывает Optimize после** и покрывает только тривиальный случай «поглощается всё целиком» (без partial overlap, без чужой ранее существовавшей partition, без `cm ≤ 0`, без проёма).
Ни один mutation-witness не целится именно в `allowCoincidentPartitions`-ветку `reconcileCoincidentPartitions` (единственную ветку, которая реально используется при создании комнаты — она отличается от ветки Optimize отключением `blockedByPartition` и допуском `cm ≤ 0`). AC13 буквально требует «мутант, оставляющий coincident partition либо draft, краснит gate» — по правилу код-ревью (PROCESS.md §2.7) защитный AC без названного свидетеля в дорогом гейте (smoke) — Medium, а не примечание, независимо от того, сколько мутантов принесла задача в целом.
### Low-1 — мёртвые поля `redundantDraftsRemoved`/`removedDrafts` в моке смока
`demo/smoke_preflight_diagnostics.mjs:53,56` (файл не тронут этим диффом) содержит литерал отчёта Optimize с полями `removedDrafts`/`redundantDraftsRemoved`, которых в реальном типе отчёта больше нет (`grep` по `src/*.ts`/`custom_components/**/*.py` на `redundantDraftsRemoved` — ноль совпадений). Не типизируется (untyped `.mjs`), не ловится typecheck; безвредно на рантайме, но нарушает §12.6 ТЗ («мёртвые ключи удалить… один вариант должен быть одинаков в… tests») буквально по букве, раз задача уже прошлась по всем остальным упоминаниям поля.
### Low-2 — churn ID активной цепочки при отклонённом клике
`src/houseplan-editor-runtime.ts` — при неудачном клике внутри цепочки `_clearGeometryGesture()` обнуляет `_activeWallChainId`, а следующий клик генерирует новый id (`_activeWallChainId ||= 'chain-...'`) даже когда `_activeWallChainPartitionIds`/`_path`/`_wallChainSegmentCms` корректно откачены к `before`. Данные не теряются, только внутренний id меняется без надобности — может вызвать лишний re-key в Lit по `_activeWallSourceKey`, но не потерю данных. Снимаю с записью: не блокирует и не имеет наблюдаемого пользователем следствия по коду рендера.
## Что проверено и корректно
- Основная геометрия поглощения (`reconcileCoincidentPartitions`, `src/coincident-partitions.ts:203-502`): breakpoints признаются поглощаемыми только при доказанном owner+kind+cm match, непоглощённые остатки корректно переизлучаются как partitions с ID по канонической `a`/`b`-ориентации (не зависящей от направления исходной записи) — соответствует §9.3. Openings либо полностью переезжают на room-wall, либо остаются на верном остатке, либо (fail-closed) всё поглощение партиции отменяется — проверено чтением и подтверждено смоком `smoke_optimize_coincident_partition` (`applyUsesOneAtomicWrite`, `undoRestoresHostedPartition`: true).
- Session-only контракт активной цепочки (§8): первый клик не пишет config, каждый следующий даёт один partition + один history/save; переключение инструмента/режима/пространства и Esc завершают цепочку без изменения уже сохранённых partitions — прослежено по коду (`houseplan-editor-runtime.ts`, `houseplan-card.ts`) и подтверждено смоками `smoke_v8_draft_write` (`rejectedWriteRollsBackOptimisticWall`), `smoke_wall_chain_thickness` (`toolChangeOnlyEndsSession`).
- Быстрый путь клика (#461) сохранён и не деградировал к полному preflight — `demo/benchmark_wall_draw_click.mjs` считает реальные счётчики (`fullSpacePhysicalChecks === 0`, `localPhysicalChecks === 1`), мутант `wall-draw-full-preflight-again` красит именно этот тест.
- i18n: все 4 локали (`de/en/fr/ru`) синхронно убрали одни и те же 14 draft-специфичных ключей и добавили одни и те же 3 новых; `test/i18n.test.mjs` реально проверяет парность ключей набором `deepEqual`.
- Миграция `room_drafts` → `partitions` для «чистого» входа (существующий ID, валидный `cm`, без null-полей) даёт байтово идентичный результат в TS и Python — подтверждено общими фикстурами `test/fixtures/282-wall-identity-parity.json` и `319-orphan-span-migration.json`, используемыми **и** в юнит-тесте, **и** в pytest с одинаковыми ожиданиями.
- `check-docs`, три копии бандла, `model-invariants` на двух конфигурациях (синтетический large-house и мигрированный многокомнатный фикстур) — зелёные.
- CONFIG-COMPATIBILITY.md/CHANGELOG (RU+EN)/USER-GUIDE.ru.md корректно описывают новое поведение согласованными терминами («партиция», «сессионная цепочка», отсутствие resume) — сверено построчно с диффом кода.
- 17 целевых смоков из прямых совпадений выборки — зелёные (полный лог приложен к прогону в CI-логах сессии ревью, не коммитился).
## Итог
High-1 и High-2 — оба про буквальные, явно заявленные обязательные условия задачи (единая идентичность миграции между движками; отказ устаревшей записи поверх мигрированного документа), а не про периферийную деталь. Оба воспроизведены конкретным входом/трассировкой, а не общими словами. Medium-находки все в скоупе (тот же диф правил эти самые файлы) и чинятся в этой же задаче без отдельного цикла. Возврат автору.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/478-remove-room-drafts`, коммит `1cadd520c065` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `fa6d69e796845ebcaf2da97888c1d9589906e001`
```
git log --all --format='%H %T' | grep fa6d69e79684
```
- ТЗ `docs/specs/478-remove-room-drafts.md`, блоб `70719ed585a993825720f90a2483fa760747bb06`
```
git log --all --find-object=70719ed585a993825720f90a2483fa760747bb06 -- docs/specs/478-remove-room-drafts.md
```