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