Files
houseplan-card/docs/reviews/CODE-REVIEW-428-r1.md
T
Codex d4dd027b0a build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 15:23:40 +03:00

21 KiB
Raw Blame History

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 +++

Три коммита в диапазоне:

  • 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. Новая проверка:

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) устранён уточнением текста ТЗ, как и обещал автор. Блокеров нет.

Вердикт: зелёный.


Материал раунда

  • Ветка: 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