Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
21 KiB
CODE-REVIEW-428-r1
Issue: #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 +++
Три коммита в диапазоне:
bc090c0edocs(spec) — ТЗ (не предмет этого этапа);80a5a044docs: review document for #428 — опубликованный артефакт спек-ревью (не предмет этого этапа);eef7ce12fix(import): preserve missing decor asset round-trips —User-Visible: yes, продуктовый код + тесты + оба changelog + три доки в одном коммите;32dd4b30test(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. Новая проверка:
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подтверждает (см. §«Скоуп диффа» выше); trailerUser-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) устранён уточнением текста ТЗ, как и обещал автор. Блокеров нет.
Вердикт: зелёный.
Материал раунда
- Ветка:
issue/428-missing-decor-asset-roundtrip, коммит32dd4b30abb3— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
336693355ea1ce40e79303025743bcc0e41e50b2git log --all --format='%H %T' | grep 336693355ea1 - ТЗ
docs/specs/428-missing-decor-asset-roundtrip.md, блоб5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464git log --all --find-object=5018539ec5fd0e9d9ffb737cc0d0fa28e6ca2464 -- docs/specs/428-missing-decor-asset-roundtrip.md