Files
houseplan-card/docs/reviews/CODE-REVIEW-324-r1.md
T
2026-08-27 13:45:07 +00:00

19 KiB
Raw Blame History

CODE-REVIEW-324-r1

Issue: #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 снята с записью и не блокирует. Вердикт — зелёный.