Files
houseplan-card/docs/reviews/CODE-REVIEW-319-r1.md
T
2026-08-26 18:05:38 +00:00

151 lines
13 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-319-r1
**Issue:** [#319](https://github.com/Matysh/houseplan-card/issues/319) — «beta.3: бэкенд навсегда отклоняет все структурные записи после v8→v9 миграции с осиротевшим open_span»
**Трек:** `small` (ТЗ в теле issue, ревью ТЗ — комментарий, зелёное, r1)
**Ветка:** `issue/319-model-upgrade-not-outdated`
**SHA материала ревью:** `3ab6fa9f8a1ef1c35730c3fb03870f69c3e50be6` (единственный коммит поверх `origin/dev`)
**Заход:** r1 · блокирующих циклов израсходовано 0 из 2 (лёгкий трек — лимит 2)
## Скоуп
Диапазон `git diff origin/dev...HEAD`, 6 файлов:
- `custom_components/houseplan/validation.py` — единственная правка контракта: в
`validate_wall_model_transition` условие охраны «unchanged wall catalogue»
сужено с `old_model >= 8 and new_model >= 8` до
`old_model >= 8 and 8 <= new_model <= old_model`.
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md` — запись об исправлении, оба
файла в том же коммите (`User-Visible: yes`).
- `test/fixtures/319-orphan-span-migration.json` — пара `stored`(v8, с
осиротевшим `open_span`, продукт писателя v1.68.0-beta.2) /
`sent`(v9, продукт текущей миграции).
- `test/wall-segment-model.test.mjs` — тест, пинящий фикстуру к текущей
JS-миграции побайтово (защита фикстуры от дрейфа).
- `tests_backend/test_wall_segment_model.py` — регрессионный тест
`test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change`
на AC1–AC3.
Продуктовый код класса A — только Python (`validation.py`); `src/**` не
тронут. Соответствует J6 `docs/SCOPE.md` («Keep the plan true as the home
evolves» — структурные записи и апгрейд модели стен не должны намертво
блокировать редактирование).
## Как проверялось
Материал — `origin/dev...HEAD`, ручного тестирования нет, поэтому все AC
проверены исполнением, не только чтением.
| Гейт | Статус | Результат |
|---|---|---|
| `npx tsc --noEmit` | прогнан | чисто, без вывода |
| `npm test` | прогнан | `1346 pass, 0 fail, 1 skipped` (skip — `issue 281 private exact fixture`, не связан с #319, зависит от отсутствующей приватной фикстуры) |
| `npm run build` + сверка `dist/` vs `custom_components/houseplan/frontend/` | прогнан | сборка чистая, `cmp` — файлы идентичны |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прогнан | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут). Browser-smoke этим диффом не выбираются». Смоки не запускались — выбирать нечего, а не пропуск |
| `node scripts/check-docs.mjs` | **не прогнан** | не требуется: диффом не тронут `src/**` |
| `npm run golden:verify` | **не прогнан** | не требуется: видимый рендер/геометрия не меняются, правка — только серверная валидация допустимости записи |
| `node scripts/model-invariants.mjs` | **не прогнан** | не требуется: диффом не тронуты рёбра/`layout`/`marker.space`/`open_spans`-производство — только guard, решающий принять/отклонить уже готовую запись |
| `python -m pytest tests_backend -q` | прогнан (полный набор, включая `test_ha_*`) | окружение ревью по умолчанию без `homeassistant`/`.venv-backend`; установил `pytest-homeassistant-custom-component`+`home-assistant-frontend` через pip, чтобы получить реальный, а не «молчаливо пропущенный» результат. Итог: `405 passed, 1 skipped, 1 error`. Ошибка — `test_ha_upload.py::test_upload_ok`, воспроизводится дважды подряд и не по файлу диффа: teardown-ассерт `pytest-homeassistant-custom-component` о фоновом потоке (`_run_safe_shutdown_loop` не `_DummyThread`/`waitpid-`), сам тест **passed**, падает только фикстура очистки. Не относится к `validation.py`/`wall_segment_model.py`, файл `test_ha_upload.py` диффом не тронут — квирк среды ad hoc-инсталляции, не регрессия задачи |
| Performance-профили | не требуется | не названы в AC, диффом не тронуты чувствительные к перфу пути |
**Тест умеет падать (проверено, не заявлено).** Патч `validation.py`
временно откачен (`git apply -R`), новый pytest-тест
`test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change`
перезапущен изолированно:
```
FAILED …/test_wall_segment_model.py::test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change
custom_components.houseplan.validation.WallModelClientOutdatedError: stored model=8; unchanged wall catalogue
```
— ровно та ошибка, что описана в симптоме issue. Патч восстановлен
(`git apply`), дерево чистое (`git status --porcelain` пусто), тест снова
зелёный (`17 passed`).
## AC — доказательство
- **AC1** (чистая миграционная запись v8→v9 проходит). Доказано исполнением:
`validate_wall_model_transition(sent, stored)` в тесте не бросает, тест
красный до фикса (см. выше), зелёный после.
- **AC2** (то же с независимым черновиком `room_drafts`). Доказано тем же
тестом (`with_draft`), тем же красный/зелёный циклом.
- **AC3** (эхо той же версии 9→9 с изменёнными контурами и неизменным
каталогом по-прежнему отклоняется). Доказано исполнением: тот же тест,
блок `echoed`, ловит `WallModelClientOutdatedError` с сообщением
`unchanged wall catalogue`; плюс существующий
`test_stale_client_echoing_v8_catalog_gets_the_named_error` зелёный
(первая ветвь `if` не тронута правкой — граница добавлена только
условием `<= old_model`, которое не меняет поведение при `new_model ==
old_model`).
- **AC4** (ветка «легаси-клиент», `old_model >= 8 and new_model < 8`, не
изменена). Проверено чтением и исполнением: правка не касается первого
`if`-блока (строки 181–192 не тронуты диффом), существующие тесты
`test_stale_client_round_trip_is_hydrated_but_structural_change_is_rejected`
и смежные — зелёные в полном прогоне.
## Продуктовое рассуждение (не только AC)
Ослабление гарда — не дыра в целостности данных. Проверено чтением
вызывающего кода, не только заявлением автора: в `websocket_api.py`
(строки 1324–1325 и 1644–1662) `validate_wall_model_transition` вызывается
**перед** `CONFIG_SCHEMA(...)` в обоих обработчиках сохранения, и
`CONFIG_SCHEMA` всегда прогоняет `_config_wall_segment_invariants`
(`validation.py:1772`) независимо от того, что решил guard в этой задаче.
Этот независимый инвариант жёстко сверяет `room.poly`/`wall_ids` с
`wall_segments` по ключу ребра, требует `id` у сегментов черновика и не
допускает `open_spans`/`open_to` при `model_version >= 9`. Значит, даже
теоретически «сломанная» запись с поднятой версией и неизменным каталогом
не проскочит мимо структурной проверки — она либо валидна, либо отклонена
`CONFIG_SCHEMA` отдельно от гарда staleness.
Предпосылка «устаревший клиент не может поднять `model_version`» опирается
на то, что `model_version` в записи — не пользовательский ввод, а константа
сборки клиента (`WALL_SEGMENT_MODEL_VERSION`/`PLAN_MODEL_VERSION`, сейчас
`9` и в `src/wall-segment-model.ts`, и в
`custom_components/houseplan/wall_segment_model.py`, и в
`custom_components/houseplan/const.py`) — проверено чтением исходников,
согласуется с тем, что уже сверял ревью ТЗ.
## Найдено и корректно
- Правка минимальна и буквально реализует контракт ТЗ: «гард применяется
только при `new_model <= old_model`» — ровно `8 <= new_model <= old_model`
в коде.
- Комментарий у изменённой строки объясняет постпродукт-причину (#319) не
тривиальным образом — обоснован по правилам написания комментариев.
- Фикстура `test/fixtures/319-orphan-span-migration.json` — не рукописная:
`stored` подтверждённо продукт реального писателя v1.68.0-beta.2,
`sent` — реального текущего мигратора; JS-тест
`the #319 pair fixture matches the current initial migration byte for byte`
защищает её от рассинхронизации, пересчитывая `sent` из `stored` через
боевой `commitWallSegmentModel`.
- Трейлеры коммита: `Issue: #319`, `User-Visible: yes`; оба changelog
правлены в этом же коммите — соответствует правилу.
- «Одно число — один источник»: правка не вводит новую отображаемую
пользователю величину (это guard принятия/отклонения записи, а не
вычисляемое значение на экране) — раздел неприменим по существу.
- Ветка легаси-клиента (`new_model < 8`) не затронута — предотвращает
регрессию по «правка ломает ранее принятый AC» (#102-класс риска).
## Чего не проверял
- `check-docs.mjs`, `golden:verify`, `model-invariants.mjs`,
performance-профили и browser-смоки — не запускал: диффом не тронут
`src/**`, видимая геометрия/рендер и чувствительные к перфу пути;
инструмент выбора смоков подтвердил «выбирать нечего».
- HA-харнесс (`test_ha_*.py`) прогнан не на пиновком CI-образе, а на
ad hoc `pip install` в среде ревью — как и предупреждает `AGENTS.md`,
«канон — Linux CI»; здесь это использовано только для получения
неслепого, исполняемого результата вместо «not verified», а не как
замена CI-гейта. Один teardown-квирк (`test_ha_upload.py`, не файл
диффа) зафиксирован и не отнесён на счёт задачи.
- CI-прогон ветки (`https://github.com/Matysh/houseplan-card/actions/runs/32984859492`),
на который ссылается автор, отдельно не открывал — верификация в этом
документе получена независимым локальным исполнением на том же SHA.
## Вердикт
High: 0, Medium: 0. Все AC1–AC4 доказаны исполнением (не только заявлением),
тест-регрессия умеет падать (проверено откатом патча), ослабление гарда не
открывает дыру в целостности — подтверждено чтением независимого инварианта
схемы и его места вызова. Зелёный.