docs: review document for #428

Issue: #428
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-03 08:09:36 +00:00
parent 972b0e0c0a
commit 34853ba32a
+289
View File
@@ -0,0 +1,289 @@
# CODE-REVIEW-428-r1
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428) — «Экспорт с
недостающей картинкой декора не импортируется — ImportFailure на весь
документ».
Заход: r1 (первый), правила §2.9 о разборе по дельте не применяются — разбор
полный.
Проверяемый HEAD: `32dd4b30abb36403e6ca3a091e96394f1eed7277`
(ветка `issue/428-missing-decor-asset-roundtrip`, слияние от
`bdf53dc4` — `origin/dev`).
Материал: `git log --oneline origin/dev..HEAD`, `git diff origin/dev...HEAD`,
ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, зелёное ревью ТЗ
`docs/reviews/SPEC-REVIEW-428-r1.md` (High 0/Medium 0), исходный контракт
`docs/specs/051-custom-decor-images.md` AC10/AC11, тело issue и все
комментарии.
## Скоуп диффа
```
custom_components/houseplan/import_export.py | 39 +++-
docs/CHANGELOG.md | 4 +
docs/CHANGELOG.ru.md | 5 +
docs/CONFIG-COMPATIBILITY.md | 5 +
docs/USER-GUIDE.md | 3 +
docs/USER-GUIDE.ru.md | 4 +
docs/reviews/SPEC-REVIEW-428-r1.md | 188 (артефакт публикации спек-ревью)
docs/specs/428-missing-decor-asset-roundtrip.md | 266 (ТЗ + опечатка)
docs/specs/README.md | 1 +
tests_backend/test_ha_import_export.py | 193 +++
```
Три коммита в диапазоне:
- `bc090c0e` docs(spec) — ТЗ (не предмет этого этапа);
- `80a5a044` docs: review document for #428 — опубликованный артефакт
спек-ревью (не предмет этого этапа);
- `eef7ce12` fix(import): preserve missing decor asset round-trips —
`User-Visible: yes`, продуктовый код + тесты + оба changelog + три доки в
одном коммите;
- `32dd4b30` test(import): account for space id remapping —
`User-Visible: no`, только правка тестового ожидания.
`src/**`, i18n, манифесты, golden, workflows не затронуты — заявление ТЗ
подтверждено диффом.
## Как проверялось
Читал код построчно и сверял с таблицей допустимых значений из ТЗ (раздел
«Контракт manifest и валидации», п.2), а не полагался на слова автора.
### 1. Первопричина и правка `_content_state()`
`custom_components/houseplan/import_export.py:1650-1673`. Новая проверка:
```python
declared_exists = declared.get("exists_at_export")
valid_mime = isinstance(declared_mime, str) and declared_mime in _DECOR_ASSET_MIME_TYPES
missing_mime = declared_exists is False and declared_mime is None
if (declared.get("asset_id") != aid or declared.get("hash") != aid
or type(declared_exists) is not bool
or not (valid_mime or missing_mime)):
raise ImportFailure("invalid_content", ...)
```
Прогнал вручную все шесть строк таблицы ТЗ через эту формулу:
| `exists_at_export` | `mime` | ТЗ | Код |
|---|---|---|---|
| `True` | supported | допустимо | `valid_mime=True` → не падает ✓ |
| `True` | `None`/unsupported | `invalid_content` | `valid_mime=False`, `missing_mime=False` (флаг не `False`) → падает ✓ |
| `False` | supported | допустимо | `valid_mime=True` → не падает ✓ |
| `False` | отсутствует/`None` | допустимо | `missing_mime=True` → не падает ✓ |
| `False` | unsupported, включая `""` | `invalid_content` | оба флага `False` → падает ✓ |
| отсутствует/`None`/`0`/`1`/str/dict/list | любое | `invalid_content` | `type(x) is not bool` истинно для всех перечисленных (в т.ч. `0`/`1`, т.к. `type(0) is int`, не `bool`) → падает ✓ |
Совпадение точное, включая явно названный в ТЗ риск «Python принимает `0` как
`False`» — проверка идёт по `type(...) is bool`, а не по `bool(...)`.
`row["mime"] = declared_mime` сохраняет ровно исходное supplied-значение для
preview; ниже по коду `exists_on_target`/`state` вычисляются заново из
байтов target blob через SHA-256 — supplied MIME/`exists_at_export` не
становится authority для доступности (комментарий в коде это фиксирует,
и это же подтверждено чтением: значение из `declared` нигде не используется
для выбора файла или `state`).
### 2. `content_manifest()` (exporter)
`import_export.py:473-482` — рефакторинг словаря в модульную константу
`_DECOR_ASSET_MIME_BY_SUFFIX`/`_DECOR_ASSET_MIME_TYPES`, поведение не
изменилось (то же `metadata.get("mime") or <lookup by suffix>`). Экспортёр
не тронут в части `exists_at_export`/hash-логики.
### 3. Plan-only проекция (`_project_plan_only_decor`, `_validate_plan_only_document`)
Проверил отдельно, так как это не то место, которое первым приходит в
голову при чтении заголовка issue, но явно названо в ТЗ («Затронутые файлы»)
и обязательно для AC3.
До правки `_DECOR_KIND_FIELDS` не содержал ключ `"image"`, поэтому
`_project_plan_only_decor` для фигуры `kind="image"` копировал только
`_DECOR_COMMON_FIELDS` (`id, kind, color, opacity, width_cm, width`) —
`asset_id` и вся геометрия терялись. Это не косметика: `content_manifest()`
на строке `if not isinstance(aid, str): continue` тогда вообще не создавал
строку манифеста для такого объекта, а спроецированный конфиг оставлял
`decor`-объект `kind: "image"` без `asset_id`/`x`/`y`/`w`/`h` — то есть режим
«Только планировка» был категорически несовместим с любым decor-изображением
в пространстве ещё до этого исправления (не входит в текст issue, но входит
в объявленный ТЗ скоуп и необходимо для AC3).
Правка (`import_export.py:108`) добавляет `"image": ("asset_id", "x", "y",
"w", "h", "angle", "flip_h", "flip_v")` — набор аналогичен по структуре
`"furniture"`. Приватность не расширяется: `asset_id` — content-addressed
SHA-256 без встроенных данных пользователя, а геометрия decor уже
экспортировалась для всех остальных kind (`line`, `rect`, `ellipse`, `text`,
`furniture`) в том же plan-only режиме до этой правки.
`_validate_plan_only_document` (`import_export.py:809-820`) расширяет
допустимые `owner` в supplied `content_manifest` с одного `"space"` до
`"space" | ("decor" && kind=="decor_asset")` — проверил, что условие именно
конъюнкция (`and` внутри `or`), а не широкое разрешение всего `owner=="decor"`:
никакой другой decor-related kind этой веткой не пропускается.
Не-скоуп ТЗ запрещает «изменение... full/space/plan-only projection»
буквально в том же документе, где «Затронутые файлы» прямо называют
«полную image-проекцию plan-only» частью работы. Это внутреннее
противоречие текста ТЗ, а не кода: при спек-ревью (это тот же ревьюер,
зелёный вердикт с этим же диффом функций уже подразумевался разделом
«Затронутые файлы») запрет уже был прочитан как относящийся к UI выбора
режима экспорта и политике detach, а не к содержимому geometry-проекции.
Код реализует ровно то, что назвал раздел «Затронутые файлы», и ничего
сверх этого — расширения не вижу.
### 4. Тесты (`tests_backend/test_ha_import_export.py`)
Прочитал каждый новый тест построчно и убедился, что он умеет падать:
- `test_issue_428_missing_decor_asset_round_trips_in_every_export_mode`
(параметризован `full`/`space`/`plan-only`, реально идёт через
`create_export → create_preview → get_candidate → prepare_apply`, как
требует риск №4 ТЗ) — без правки `_content_state()` первый же вызов
`create_preview()` бросил бы `invalid_content` на этапе построения preview,
тест бы упал на `create_preview`, а не на assert. Дополнительно проверяет
`content_confirmation_required` до подтверждения и точное совпадение
сохранённого объекта (кроме локального `id`, который намеренно
перевыделяется при space-импорте — см. §5 ниже) после подтверждения.
- `test_issue_428_explicitly_missing_asset_accepts_bounded_mime` —
`null`/omitted/`"image/png"` при `exists_at_export: false` дают
`missing_preserved` + `confirmation=True`; без правки `null`/omitted упали
бы на `ImportFailure`.
- `test_issue_428_missing_mime_exception_remains_fail_closed` — 13
параметризованных отрицательных случаев, включая `0`/`1`/`""`/`list`/`dict`
для обоих полей раздельно; до правки часть уже падала (это regression-тест
на существующее поведение), после правки для новых legal-комбинаций
(в этом тесте таких нет — тут только invalid) все раскрываются как
`invalid_content`. Проверил вручную по формуле выше — совпадает.
- `test_issue_428_missing_asset_keeps_hash_identity_strict` — подмена
`asset_id`/`hash` при прочих валидных полях всё равно даёт
`invalid_content`: ослабление не затронуло identity-проверку.
- `test_issue_428_missing_source_reuses_only_exact_target_blob` — существующий
на target файл с точным SHA даёт `available`/`confirmation=False`; при
подмене байтов — `missing_preserved`/`confirmation=True`. Это ровно AC4.
Старый `test_issue_51_missing_decor_asset_stays_as_repairable_geometry`
(соседний случай: source had blob, target doesn't) не тронут и остаётся
зелёным регрессионным тестом — AC6 подтверждён.
Финальный коммит `32dd4b30` — чисто тестовая правка: заменяет
`imported_images == [shape]` на сравнение без ключа `id`, потому что
space-импорт намеренно перевыделяет локальные id (документировано в
`docs/USER-GUIDE.md`: «A space import assigns new internal IDs»). AC2 требует
сохранности `asset_id`, geometry, opacity, flip flags и decor order — `id`
в этот список не входит, тест корректно ослаблен только в этой одной точке,
остальные поля сравниваются на точное равенство.
### 5. Документация и changelog (AC8)
- `docs/CONFIG-COMPATIBILITY.md` — новый абзац описывает `exists_at_export:false`/
`mime:null` как импортируемое во всех трёх режимах и явно называет три
инварианта (`bool`-флаг, exact identity/hash, whitelist непустого MIME) —
соответствует контракту, не расширяет его словами.
- `docs/USER-GUIDE.md`/`.ru.md` — новое предложение продолжает существующий
абзац про «перечёркнутую рамку»/repair; в ru-версии «восстанавливаемую
рамку» — та же сущность, упомянутая двумя предложениями выше, термин не
изобретён. En-версия ссылается на «the existing missing-content
confirmation» — сверил с §20 (строка 966-968 USER-GUIDE.md): «Import first
shows a server-side preview with... content-link state; nothing is written
until confirmation» — тот же механизм, разночтения нет.
- Оба changelog правлены в том же коммите `eef7ce12`, что и продуктовый код —
`git show --stat` подтверждает (см. §«Скоуп диффа» выше); trailer
`User-Visible: yes` на этом коммите корректен.
## Гейты
**Дешёвые гейты подтверждены на этом SHA** (`32dd4b30`), Validate
https://github.com/Matysh/houseplan-card/actions/runs/33730976160 —
`conclusion: success`, проверил `headSha` через `gh run view --json headSha`
и он равен HEAD ветки. Разбор по job:
| Job | Статус на 32dd4b30 | Почему так |
|---|---|---|
| `docs`, `provenance`, `process-gate`, `hacs`, `hassfest` | success | реально выполнены |
| `frontend`, `smoke`, `golden`, `performance_smoke` | skipped | `src/**` не тронут — path-filter `changes`, легитимно (диф подтверждён выше: только backend+docs) |
| `backend` | skipped (переиспользование) | не «пропущено молча»: я прочитал лог job «Переиспользование» — `reuse-backend-<hash>` дал `Cache hit`, т.е. байт-в-байт то же содержимое `import_export.py`+`test_ha_import_export.py`, что и в прогоне `f0d36e8d` (id `33729239357`), где backend **реально выполнялся** и завершился `success`. Проверил `git diff f0d36e8d HEAD -- tests_backend/test_ha_import_export.py custom_components/houseplan/import_export.py` — пусто, разница между этим SHA и HEAD только в `PROCESS.md`/`scripts/gate-reuse.mjs` (rebase на dev), к #428 не относится. Переиспользование корректно. |
Не прогонял `npm run typecheck`, `npm test`, `npm run build` — фронтенд не
тронут, а Validate на этом SHA формально зелёный (`docs`/`provenance` job
покрывают то немногое, что относится к дереву в целом). `python -m pytest
tests_backend -q` не прогонял локально — по правилу AGENTS.md он без HA
молча пропускает `test_ha_*.py` (ровно те тесты, что здесь важны), поэтому
такой прогон ничего бы не доказал; полагаюсь на подтверждённый Linux-CI
прогон `f0d36e8d`.
Не прогонял и не требовались по AC9/ТЗ: browser smokes (`src/**` не тронут),
`golden:verify` (визуальный результат не меняется), `invariants`
(геометрия/`layout`/`marker.space`/`open_spans` не тронуты — диф не
содержит правок стен, рёбер комнат, layout или толщины), performance-профили
(не названы в AC, perf-чувствительные пути не тронуты). `ruff` не
перезапускал — автор сообщил зелёный локальный прогон, а ruff не входит в
список Validate-джобов из AGENTS.md; проверка кода не выявила ничего, что
ruff обычно ловит (неиспользуемые импорты/переменные не вижу).
## Находки
Не найдено. High: 0, Medium: 0, Low: 0.
## Что проверено и корректно
- Матрица валидации `decor_asset` (6 строк ТЗ) реализована в
`_content_state()` буквально, включая различение `bool`/`int` для
`exists_at_export` и включая `""` в список отклоняемых MIME.
- Supplied `mime`/`exists_at_export` не становятся authority для
`state`/`exists_on_target` — эти поля пересчитываются из байтов target
blob по SHA-256 независимо от заявленных значений (AC4, риск №3 ТЗ).
- Три режима экспорта (`full`, `space`, `plan-only`) реально проверены через
`create_export`/`create_preview`/`prepare_apply`, а не только helper'ами
(риск №4 ТЗ снят).
- Ранее принятые строки (`true`+supported MIME, `false`+supported MIME)
не меняют поведения — AC7 подтверждён и построчным разбором, и
параметром `"supported"` в тесте.
- Версии/схемы/миграции не тронуты (`grep` по `VERSION` в диффе пуст) — AC7.
- Оба changelog и обе документации правлены в implementation-коммите с
верным trailer — AC8.
- Побочный дефект plan-only проекции decor-изображений (потеря `asset_id`
и геометрии) исправлен как часть заявленного в ТЗ скоупа, не является
скрытым расширением задачи, и не открывает новую приватность — раздел
«Затронутые файлы» ТЗ его прямо называет.
## Чего не проверял
- Не запускал `npm run typecheck`/`test`/`build` — фронтенд вне диффа,
Validate уже зелёный на точном SHA.
- Не запускал полный HA-harness локально — канон это Linux CI, зелёный на
`f0d36e8d` при байт-идентичном коде backend-файлов.
- Не проверял ручным тестированием в браузере — диф не касается `src/**`,
видимого рендера нет.
- Не проверял decor order при нескольких image-объектах одновременно (тест
использует один объект на пространство) — это пред-существующая глубина
покрытия теста #51/#428, не регресс этого диффа, и не входит ни в один
из шести Not-a-bug рисков ТЗ.
## Вывод
Реализация точно соответствует контракту ТЗ #428 (матрица допустимых
значений, target-reuse, три режима экспорта, совместимость), тесты
осмысленны и способны падать, документация и changelog обновлены в
правильном коммите с верными трейлерами. Единственный найденный при
спек-ревью Low (пустая строка MIME) устранён уточнением текста ТЗ, как и
обещал автор. Блокеров нет.
**Вердикт: зелёный.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/428-missing-decor-asset-roundtrip`, коммит `32dd4b30abb3` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `336693355ea1ce40e79303025743bcc0e41e50b2`
```
git log --all --format='%H %T' | grep 336693355ea1
```
- ТЗ `docs/specs/428-missing-decor-asset-roundtrip.md`, блоб `5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464`
```
git log --all --find-object=5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464 -- docs/specs/428-missing-decor-asset-roundtrip.md
```