# CODE-REVIEW-324-r1 Issue: [#324](https://github.com/Matysh/houseplan-card/issues/324) — Model v8/v9: создание пространства отклоняется из-за отсутствующего `wall_segments`. Трек: `trivial` (короткий, лимит код-ревью — 2 цикла). Заход: r1 · SHA материала: `69e410fea3d259fab6fac6e14d0d24d23893ec06` (== `git rev-parse HEAD` на момент разбора, `origin/dev` — предок ветки, ребейз не требуется). Ветка: `issue/324-space-wall-segments`, коммит один, трейлеры `Issue: #324` / `User-Visible: yes` на месте. ## Скоуп По `docs/SCOPE.md` задача — базовая операция CRUD над пространствами (J6 «Keep the plan true as the home evolves»), core-функциональность, формально отклонявшаяся backend-инвариантом. Прямо в скоупе. Диапазон разбора — весь diff `origin/dev...HEAD` (это первый заход, дельты предыдущего раунда нет): - `src/houseplan-card.ts` — фабрика нового space заменена на `createEmptySpaceConfig`; в `catch` блока `_saveSpaceDialog()` добавлен `_reloadConfigOnly(true)` при любом отказе, кроме `conflict`. - `src/space-dialog.ts` — новая экспортируемая `createEmptySpaceConfig(id, title)`, материализующая `wall_segments: []`. - `test/space-dialog.test.mjs` — unit-тест новой фабрики. - `demo/smoke_space_create_display_defaults.mjs` — добавлен сценарий reject/reload/retry для create. - `tests_backend/test_validation.py` — негативный regression-тест: v8/v9 space без ключа `wall_segments` по-прежнему отклоняется backend'ом с точным текстом ошибки. - `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md` — бюллетень в обоих файлах, тот же коммит. - `docs/images/screenshots.json`, `dist/**`, `custom_components/.../frontend/**` — ожидаемые производные правки (фингерпринт `src/**`, синхронизация бандла). ## Как проверялось — гейты | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | зелёный, без вывода | | unit | `npm test` | 1360 passed / 1 skipped (в т.ч. новый тест `space-dialog.test.mjs`: `ok 1002 - a fresh space is a complete empty v8/v9 wall-model document (#324)`) | | build + копии бандла | `npm run build && npm run bundle:sync`, затем `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | зелёный, три копии побайтово совпадают, `git status --porcelain` после пересборки пуст | | docs fingerprint | `node scripts/check-docs.mjs` (обязателен: diff трогает `src/**`) | `Documentation checks passed (7 files, 10 external links)` — записанный `sourceFingerprint` в `docs/images/screenshots.json` актуален, повторения #230/#234 нет | | smoke-select | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прямое совпадение по символу `_reloadConfigOnly` (единственный тронутый в `houseplan-card.ts` вызываемый символ) — 7 смоков: `smoke_linked_virtual_light`, `smoke_optimize_coordinate_canonicalization`, `smoke_plan_upload_race`, `smoke_save_race`, `smoke_v8_draft_write`, `smoke_virtual_light_toggle`, `smoke_ws_resilience` | | целевой smoke | `node demo/smoke_space_create_display_defaults.mjs` (файл в diff и назван по факту как регрессия #324) | **OK**, включая новые проверки `savedMixedHasEmptyWallCatalog`, `rejectedCandidateHasEmptyWallCatalog`, `rejectedCreateReloadsServerTruth`, `rejectedCreateKeepsDialogForRetry` | | 7 смоков из smoke-select | `node demo/smoke_{linked_virtual_light,optimize_coordinate_canonicalization,plan_upload_race,save_race,v8_draft_write,virtual_light_toggle,ws_resilience}.mjs` | все **OK** | | backend | `python -m pytest tests_backend/test_validation.py -q` | **не прогнан** — в этом окружении вообще нет модуля `pytest` (`No module named pytest`), не только отсутствует HA-harness; `.venv-backend` не создан (по AGENTS.md он появляется только в облачном агенте). Продуктовый backend-код (`validation.py`) в diff не тронут — правка только в `tests_backend/test_validation.py`. Файл тестов декларирует независимость от HA (`_ROOT`/`sys.modules` заглушки в начале файла), так что его можно было бы прогнать и без харнесса, но не в данном окружении. Проверено чтением — см. ниже | | golden / model-invariants | — | сознательно не прогонялись: diff не меняет геометрию (пустой `wall_segments: []`, никакой реальной стены/комнаты) и не меняет рендер — `imageSha256` во всех сценариях `screenshots.json` не изменились, только пересчитан `sourceFingerprint`/`sourceSha256` от правки `src/**` | | full smoke set / performance | — | не прогонялись сознательно — не предрелизный гейт, задача не задевает весь фронтенд (§8 PROCESS.md: полные наборы — обязанность пре-релиза) | **Дисциплина «тест должен уметь падать».** Проверено практически: временно откатил `src/houseplan-card.ts` и `src/space-dialog.ts` к состоянию до фикса (`git apply -R` на diff этих двух файлов), пересобрал бандл (`npm run bundle:sync`) и перезапустил целевой smoke: ``` FAILED (3): - savedMixedHasEmptyWallCatalog: expected true, got false - rejectedCandidateHasEmptyWallCatalog: expected true, got false - rejectedCreateReloadsServerTruth: expected true, got false ``` После проверки патч возвращён (`git apply` тем же diff'ом), пересборка повторена, `git status --porcelain` пуст — дерево в исходном committed состоянии. Тест реально способен упасть на старом поведении ровно там, где описан баг. Аналогично backend-регрессия (`test_zero_wall_style_and_v9_legacy_fields_are_strict`, новая ветвь) проверена чтением: `v.CONFIG_SCHEMA` вызывает `_config_wall_segment_invariants`, которая при `model >= 8` и `space.get("wall_segments") is None` бросает ровно `vol.Invalid("v8+ space requires wall_segments")` (`custom_components/houseplan/validation.py:1785-1787`) — текст совпадает с `pytest.raises(..., match=r"v8\+ space requires wall_segments")` в тесте. Логика инварианта в этом diff не менялась (не в скоупе — issue прямо запрещает «Ослабление v8/v9 schema»), только добавлен негативный кейс. ## AC — построчно **AC1** «Создание пустого пространства из current v8/v9 config отправляет `wall_segments: []`, сохраняется без ошибки и переживает повторный `config/get`/reload.» — Доказано: `createEmptySpaceConfig` (`src/space-dialog.ts:11-23`) всегда кладёт `wall_segments: []`; unit-тест фиксирует точную форму объекта; smoke `savedMixedHasEmptyWallCatalog` подтверждает поле в принятом `_serverCfg` после успешного `_saveSpaceDialog()`. «Переживает `config/get`» — backend-тест `assert v.CONFIG_SCHEMA(canonical) == canonical` с `wall_segments: []` в составе `canonical` доказывает точный round-trip через схему без изменения или потери поля (`tests_backend/test_validation.py:1716-1720`). Полного E2E-цикла save→reconnect→config/get в смоке нет, но комбинация unit + backend round-trip + чтения кода закрывает критерий для `trivial`-объёма. **AC2** «Отказ `config/set` при создании или редактировании пространства не оставляет локальную конфигурацию отличающейся от серверной; последующее удаление не наследует rejected pending write.» — Ветка **create** доказана smoke-сценарием: мок `config/set` бросает `invalid_format`, `rejectedCreateReloadsServerTruth` подтверждает ровно один авторитетный `config/get` и отсутствие space-призрака в `_serverCfg`. — Ветка **edit** отдельным тестом не покрыта, но `catch` в `_saveSpaceDialog()` (`src/houseplan-card.ts:15080-15095`) общий для create и edit — `_reloadConfigOnly(true)` вызывается для любого отказа с `e.code !== 'conflict'` независимо от `d.mode`. Проверено чтением, не исполнением. — Пункт про **delete не наследует rejected write** доказан по коду, не исполнением: `_reloadConfigOnly(true)` идёт по пути `_adoptStructuralResponses` (`src/houseplan-card.ts:3841-3855`), которая при изменившемся отпечатке делает `this._serverCfg = nextConfig` — **полную замену**, а не merge; призрак и любой другой локальный «хвост» отклонённого write исчезают вместе с самим `_serverCfg`. Последующий `_deleteSpace()` строит свой запрос уже из чистого `_serverCfg`, так что отклонённый кандидат физически не может попасть в delete-flush. Отдельного смока на цепочку «rejected create → delete» нет; для `trivial`-задачи со сложностью 2/10 и общим catch-путём это разумная экономия, а не пробел. **AC3** «Backend по-прежнему отклоняет v8/v9 space без `wall_segments`; frontend unit/regression test фиксирует корректный create candidate и rollback/reload path.» — Backend-инвариант не тронут (см. diff), новый негативный тест подтверждён чтением (см. выше). Frontend: unit-тест `createEmptySpaceConfig` + smoke `rejectedCandidateHasEmptyWallCatalog`/`rejectedCreateReloadsServerTruth`/ `rejectedCreateKeepsDialogForRetry` — все выполнены и способны падать (продемонстрировано откатом фикса выше). Все три AC выполнены. ## Находки ### Low — избыточный повторный `config/get` при `physicalGeometryRolledBack` `src/houseplan-card.ts:15088` — новая строка `if (e?.code !== 'conflict') await this._reloadConfigOnly(true);` защищена только от дублирования на ветке `conflict` (та уже перечитывается внутри `_saveConfigNow`, строка 15171). Но `_saveConfigNow` также самостоятельно перечитывает конфиг на `e?.physicalGeometryRolledBack` (строка 15170, через `_reloadRejectedPhysicalWrite()` → `_reloadConfigOnly(true)`), а у такой ошибки `e.code` не установлен — значит условие `!== 'conflict'` истинно, и `_saveSpaceDialog` перечитывает конфиг ещё раз. **Воспроизведение (по коду, не по исполнению):** пользователь тащит стену (геометрический write встаёт в `_pendingPhysicalWrites`, ещё не подтверждён сервером) и почти одновременно жмёт Save в диалоге пространства. Оба write идут через общий `_writeChain`; если объединённый candidate отклонён сервером, `_writeConfig()` синхронно откатывает физическую геометрию и помечает ошибку `physicalGeometryRolledBack`. `_saveConfigNow` уже сделал `_reloadConfigOnly(true)` для этого случая; `_saveSpaceDialog`'s catch делает его снова. Последствий для корректности нет — `_reloadConfigOnly` идемпотентен, `_adoptStructuralResponses` просто ещё раз применит тот же ответ сервера. Цена — один лишний `houseplan/config/get` и лишний render в редком конкурентном сценарии (одновременная геометрическая правка + сохранение диалога пространства), которого ни один AC не описывает. Комментарий в коде («Conflict is already reloaded by `_saveConfigNow`; do not issue the same read twice») формально верен только для `conflict`, не для `physicalGeometryRolledBack` — это неточность комментария, а не баг поведения. **Решение ревьюера:** снимаю без правки. Узкий, не бьёт по данным, не описан ни одним AC этой `trivial`-задачи, исправление потребовало бы трогать общий catch двух независимых путей отката — за рамками сложности 2/10, заявленной автором. Если будущая задача тронет этот catch по другому поводу, уточнить комментарий заодно. Нет находок Medium или High. ## Что проверено и корректно - Фабрика `createEmptySpaceConfig` идентична старому литералу плюс `wall_segments: []`; никаких побочных полей не потеряно (сверено построчно со старым инлайн-объектом в diff). - `canonicalizeConfigGeometryInPlace` (`src/coordinate-canonicalization.ts:324-327`) корректно проходит по `space.wall_segments`, если они есть — новая фабрика не создаёт для него специального случая, пустой массив обрабатывается тем же кодом что и непустой. - К моменту, когда пользователь может открыть диалог создания пространства, `_serverCfg.model_version` уже мигрирован до текущего (`WALL_SEGMENT_MODEL_VERSION`, `src/wall-segment-model.ts:819-827`) — безусловное добавление `wall_segments: []` в новый space не создаёt несовместимости с «старыми» конфигами: на момент create конфиг уже v9. - i18n: новых ключей нет, диалог переиспользует существующие `toast.error`/`toast.space_added`. Соответствует критерию `trivial` («нет новых ключей i18n»). - «Одно число — один источник»: diff не добавляет и не меняет ни одной видимой пользователю величины (это структурное поле модели, не отображается), правило неприменимо к этой задаче. - Оба changelog правлены в том же коммите, что и поведение (`git show --stat` на единственном коммите ветки); трейлеры `Issue: #324` / `User-Visible: yes` присутствуют и корректны. - Ветка чистая, один коммит, `origin/dev` — предок HEAD, конфликта слияния не будет. ## Чего не проверял - `python -m pytest tests_backend/test_validation.py` — не прогнан живьём: в окружении ревью нет модуля `pytest` вовсе (не только HA). Backend-логика инварианта не менялась (см. выше «Как проверялось»), новый тест верифицирован чтением обеих сторон (текст ошибки/условие). - Полный HA-харнесс (`test_ha_*.py`) — вне скоупа диффа, backend-код не тронут. - `npm run golden:verify`, `npm run docs:accept`/пересъёмка скриншотов — не требуются: `check-docs.mjs` подтвердил актуальный fingerprint, ни один `imageSha256` не изменился, diff не меняет геометрию/рендер. - `node scripts/model-invariants.mjs` — не требуется: diff не создаёт реальной геометрии (только пустой каталог `wall_segments: []` для пространства без комнат и стен), рёбер/ссылок на них нет. - Полный `demo/smoke_*.mjs` набор (192 файла) и `performance_smoke` — не предрелизный гейт этой задачи; выборка smoke-select плюс целевой smoke покрывают тронутый символ и сам файл diff'а. - Сценарий «отклонённый **edit** (не create) → повторный save» и «rejected create → следующий delete» как явные E2E-смоки — не выполнены вживую, закрыты чтением общего кода (см. AC2). Это осознанное сужение объёма гейтов под сложность задачи 2/10, а не пропуск. - Тест на `mcp__github__get_issue`/комментарии выполнен через `gh issue view` (MCP-инструмент был недоступен в сессии — отказ в разрешении); содержимое issue и комментариев получено, на выводы это не повлияло. ## Итог Все три AC выполнены и доказаны — частично исполняемыми тестами (проверено, что они умеют падать), частично чтением с явной пометкой. High-находок нет, единственная Low снята с записью и не блокирует. Вердикт — зелёный.