mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
290 lines
21 KiB
Markdown
290 lines
21 KiB
Markdown
# 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
|
||
```
|