From bc090c0e40925003362f57429fe50e4d6a0bc6fc Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 3 Sep 2026 10:24:52 +0300 Subject: [PATCH] docs(spec): define missing decor asset round-trip Issue: #428 User-Visible: no --- .../428-missing-decor-asset-roundtrip.md | 265 ++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 266 insertions(+) create mode 100644 docs/specs/428-missing-decor-asset-roundtrip.md diff --git a/docs/specs/428-missing-decor-asset-roundtrip.md b/docs/specs/428-missing-decor-asset-roundtrip.md new file mode 100644 index 00000000..9271c0a8 --- /dev/null +++ b/docs/specs/428-missing-decor-asset-roundtrip.md @@ -0,0 +1,265 @@ +# ТЗ #428 — round-trip экспорта с отсутствующей картинкой декора + +Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428) + +Статус документа: ТЗ на ревью. + +Источник контракта: [ТЗ #51](051-custom-decor-images.md), раздел +«Import/export и совместимость», AC10 и AC11. + +## Сценарий + +1. В конфигурации пространства сохранён `decor`-объект `kind: image` с + корректным 64-символьным SHA-256 `asset_id`. +2. Соответствующего blob и metadata sidecar уже нет в + `/houseplan/assets/`. +3. Пользователь экспортирует полный дом, одно пространство либо только + планировку, а затем пытается импортировать полученный JSON. + +Сейчас exporter честно записывает для объекта `exists_at_export: false` и +`mime: null`, но importer требует MIME из белого списка для любой строки +`decor_asset`. Поэтому House Plan отклоняет весь собственный экспорт как +`invalid_content`, хотя #51 определяет missing asset как легальное, +восстанавливаемое состояние. + +После исправления документ проходит preview, сообщает об отсутствующем файле, +требует действующее явное подтверждение и сохраняет image-объект вместе с его +геометрией как repair-placeholder. Остальной импорт не меняется. + +## Что человек увидит до и после + +| Состояние | Сейчас | После исправления | +|---|---|---| +| Файл картинки отсутствовал уже при экспорте | Импорт всего JSON завершается ошибкой | Preview открывается, показывает missing content и требует подтверждение | +| Пользователь подтверждает импорт без файла | До подтверждения невозможно дойти | Объект и его геометрия сохраняются; во View не рисуется, в Background editor доступен для замены | +| Файл с тем же exact hash уже есть на целевой системе | Документ всё равно отклоняется из-за `mime: null` | Локальный blob проверяется по SHA-256 и переиспользуется как `available` | + +Нового диалога, текста ошибки или элемента управления нет. + +## Подтверждённая причина + +- `custom_components/houseplan/import_export.py::content_manifest()` получает + MIME из metadata sidecar либо расширения найденного blob. Если оба файла + отсутствуют, результат — `None`; флаг `exists_at_export` при этом равен + `False`. +- `_content_state()` повторно строит ожидаемые ссылки из payload, но затем + безусловно требует у supplied `decor_asset` MIME из множества + `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`. +- `test_issue_51_missing_decor_asset_stays_as_repairable_geometry` покрывает + только соседний случай: blob был у источника и потому MIME был известен, но + blob отсутствует на target. + +## Скоуп + +- ограниченно скорректировать валидацию `decor_asset` в `_content_state()`; +- сохранить строгую сверку manifest с image-ссылками, заново выведенными из + payload; +- покрыть настоящий round-trip «export при отсутствующем blob/sidecar → import + preview» и отрицательную матрицу manifest; +- уточнить контракт missing decor asset в `docs/CONFIG-COMPATIBILITY.md`, + `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`; +- добавить пользовательскую запись в оба changelog. + +## Не-скоуп + +- встраивание blob/base64 в JSON; +- восстановление, загрузка, перенос или автоматическое удаление asset-файлов; +- угадывание MIME из `asset_id`: content-addressed id не содержит расширение; +- изменение формата export, `EXPORT_FORMAT_VERSION`, config/model schema либо + storage layout; +- изменение UI подтверждения, placeholder, счётчиков preview, delete/replace, + full/space/plan-only projection или политики внешних файлов; +- ослабление проверки любых manifest-строк, кроме строго описанного ниже + missing `decor_asset`. + +## Контракт manifest и валидации + +### 1. Канонический экспорт + +Exporter продолжает выдавать одну extension-neutral строку `decor_asset` на +каждый image record. Поля `asset_id` и `hash` равны canonical lowercase SHA-256, +`exists_at_export` всегда имеет настоящий тип `bool`. + +- Если verified source blob существует, `exists_at_export` равно `true`, а + `mime` обязательно входит в поддерживаемый белый список. +- Если verified source blob отсутствует либо не проходит exact hash, + `exists_at_export` равно `false`. `mime` может быть поддерживаемой строкой, + когда её сохранил валидный metadata sidecar, либо JSON `null`, когда MIME + достоверно неизвестен. + +Exporter не восстанавливает MIME эвристикой и не добавляет bytes. + +### 2. Допустимые строки при импорте + +До определения локального состояния target importer проверяет supplied +`decor_asset` по следующей матрице: + +| `exists_at_export` | `mime` | Результат | +|---|---|---| +| literal `true` | поддерживаемая строка | допустимо | +| literal `true` | отсутствует, `null` или неподдерживаемая строка | `ImportFailure("invalid_content")` | +| literal `false` | поддерживаемая строка | допустимо | +| literal `false` | отсутствует или `null` | допустимо: это исправляемый missing asset | +| literal `false` | неподдерживаемая непустая строка либо значение другого типа | `ImportFailure("invalid_content")` | +| поле отсутствует, `null`, `0`, `1`, строка, объект или массив | любое | `ImportFailure("invalid_content")` | + +Во всех допустимых строках остаются обязательными: + +- exact equality supplied `asset_id` и `hash` с SHA-256, выведенным из payload; +- exact identity строки (`kind`, `owner`, `owner_id`, `field`, `url`) и отсутствие + лишних/дублированных/пропущенных строк; +- повторная проверка target blob чтением bytes и сравнением SHA-256. + +Поддерживаемый MIME — только `image/png`, `image/jpeg`, `image/webp` или +`image/svg+xml`. Неподдерживаемый указанный MIME нельзя маскировать +`exists_at_export: false`. + +### 3. Локальное состояние target + +Источник не определяет доступность на целевой системе: + +- target blob с exact hash → `state: available`, `exists_on_target: true`, + отдельное подтверждение для этой строки не требуется; +- target blob отсутствует, нечитаем или hash не совпадает → + `state: missing_preserved`, `exists_on_target: false`, общий preview получает + `confirmation_required: true`; +- после подтверждения `_detach_missing()` не удаляет image record: `asset_id`, + geometry, opacity, flips и decor order сохраняются по контракту #51. + +Значение `exists_at_export` в нормализованной preview-строке остаётся значением +из supplied manifest. `mime: null` не превращается в MIME найденного либо +предполагаемого файла и не становится authority. + +## Совместимость и миграция + +- Миграции config/model/storage нет. +- Номер export format не меняется: исправленный importer принимает ранее + сгенерированный самим House Plan v2 документ, который уже соответствовал + заявленному контракту `exists_at_export: false`. +- Все документы, которые принимались раньше, продолжают приниматься. +- Старый importer может по-прежнему отвергнуть такой JSON; исправление не может + сделать уже установленную старую версию совместимой вперёд. +- Отсутствующий `exists_at_export` не трактуется как legacy default: exporter + v2 всегда записывает поле, а fail-closed поведение защищает границу доверия. + +## Безопасность и privacy + +Manifest остаётся описательным, не авторитетным. Payload определяет полный набор +ссылок, а target bytes — фактическую доступность. Исключение для отсутствующего +MIME связано одновременно с exact image identity и literal false; оно не даёт +подсунуть внешний URL, пропустить ссылку, объявить другой hash/MIME или обойти +проверку файла. Новых данных в export и новых путей к файловой системе нет. + +## Touch, accessibility, i18n и производительность + +- Touch/View/kiosk не меняются: это backend round-trip до существующего preview. +- Новых текстов и ключей i18n нет; используются действующие missing-content + confirmation и repair-placeholder. +- На каждый asset остаются тот же один bounded поиск кандидатов и, при наличии + blob, один SHA-256 проход. Новых обходов, сетевых запросов и frontend bundle + кода нет; performance budgets не меняются. + +## Затронутые файлы и модули + +- `custom_components/houseplan/import_export.py` — bounded validation + `decor_asset` в `_content_state()`; +- `tests_backend/test_ha_import_export.py` — положительный round-trip и + отрицательная матрица; +- `docs/CONFIG-COMPATIBILITY.md` — точное значение missing MIME; +- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — пользовательское правило + повторного экспорта/import missing image; +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — release note; +- этот файл и `docs/specs/README.md` — трассируемость ТЗ. + +Frontend `src/**`, i18n JSON, version files, screenshots/golden и workflow не +затрагиваются. + +## Критерии приёмки + +- **AC1 — настоящий missing round-trip (backend).** Full export, созданный при + отсутствии blob и sidecar, содержит `exists_at_export: false, mime: null`; + его import preview не падает, выдаёт `missing_preserved` и требует + подтверждение. +- **AC2 — сохранение объекта (backend).** После подтверждённой подготовки + импорта image record сохраняет exact `asset_id`, geometry, opacity, flip flags + и decor order; `_detach_missing()` его не удаляет. +- **AC3 — три режима экспорта (backend).** Общая validation path доказана для + full, single-space и plan-only export: missing image row во всех режимах + импортируема по одному контракту, а projection/privacy каждого режима не + меняются. +- **AC4 — target reuse (backend).** Для строки + `exists_at_export: false, mime: null` existing target blob переиспользуется + только после exact SHA-256 проверки и получает `available` без ложного + missing confirmation. +- **AC5 — fail-closed MIME/flag (backend).** Параметризованный отрицательный + тест доказывает матрицу: null/omitted MIME допустим только при literal false; + unsupported non-null MIME отклоняется и при false; отсутствующий и любой + не-bool `exists_at_export` отклоняются. +- **AC6 — identity invariants (backend).** Mismatched `asset_id`/`hash`, лишняя, + пропущенная или дублированная manifest row по-прежнему дают + `invalid_content`; существующее покрытие остаётся зелёным. +- **AC7 — совместимость (backend + ревью кода).** Ранее допустимые строки + `exists_at_export: true` с поддерживаемым MIME и `false` с поддерживаемым MIME + не меняют результат; config/model/export version не повышается. +- **AC8 — документация и release (docs gate + ревью кода).** Оба User Guide и + compatibility doc описывают импортируемый missing round-trip; оба changelog + обновлены в том же `User-Visible: yes` implementation commit. +- **AC9 — гейты (commands + Linux CI).** В цикле реализации зелёные + `npm run typecheck`, `npm test`, `npm run build` и targeted backend tests; + полный HA harness остаётся каноническим Linux CI. Golden, smoke и performance + не требуются до команды на бету, поскольку визуальный/frontend output не + меняется. + +## План автотестов + +1. Добавить helper fixture с image record и отсутствующим source asset. +2. Параметризовать `kind=full`, `kind=space` и plan-only variant; создавать + документ через `create_export()`, не конструировать только вручную. +3. Для полного пути пропустить JSON через `create_preview()` на отдельном + пустом target root и проверить content state, confirmation и сохранённый + candidate payload. +4. Создать exact target blob без sidecar и доказать `available`; затем заменить + bytes и доказать `missing_preserved`. +5. Параметризовать `exists_at_export` и `mime` по таблице, включая Python + `False` отдельно от `0`, потому что `bool` — подкласс `int`. +6. Не заменять существующий тест #51: он остаётся регрессией для known MIME при + missing target. + +## Release-артефакты + +- В `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — одна парная запись о том, + что повторно экспортированный план с отсутствующей пользовательской картинкой + снова импортируется с подтверждением и без потери объекта. +- В обоих User Guide — короткое уточнение рядом с missing image/import rules. +- В `docs/CONFIG-COMPATIBILITY.md` — нормативная truth table в компактной форме. +- Screenshots/golden не обновляются: видимый рендер и UI не меняются. +- Release/version/tag не входят в задачу; issue остаётся открытой в S8 до беты. + +## Риски + +1. **Слишком широкое ослабление manifest.** Снимается точной проверкой + `type(exists_at_export) is bool`, белым списком непустого MIME и неизменной + exact identity/hash validation. +2. **Python принимает `0` как `False`.** Проверка должна быть по типу и identity, + а тест содержит `0` и `1` как отрицательные значения. +3. **Source metadata становится authority.** Нельзя использовать supplied MIME + или availability для выбора target файла; target hash проверяется как раньше. +4. **Проверен helper, но не настоящий export/import.** AC1 и AC3 требуют + документы от `create_export()` и хотя бы один путь через `create_preview()`. + +## Откат + +Откат — revert implementation commit: importer снова потребует supported MIME +у каждой строки `decor_asset`. Данных и миграций откатывать не нужно; уже +импортированные image records остаются валидными по схеме #51. Цена отката — +возврат исходной невозможности импортировать собственный export с missing asset. + +## Принятые предположения + +- `exists_at_export: false` означает только подтверждённое exporter-ом отсутствие + verified source blob; оно не обещает отсутствие exact blob на target. +- `mime: null` — единственное корректное представление неизвестного MIME, + которое пишет текущий exporter; отсутствие ключа принимается эквивалентно + только в той же строго missing-ветке для устойчивости JSON producers. +- Восстановимый MIME нельзя получить из SHA-256 `asset_id` без blob/sidecar, + поэтому исправляется importer, а не вводится недостоверное значение exporter-а. diff --git a/docs/specs/README.md b/docs/specs/README.md index e13d52c1..3a313932 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -84,6 +84,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#314](https://github.com/Matysh/houseplan-card/issues/314) Атомарная запись v8 drafts и независимой геометрии | [314-v8-draft-write-regression.md](314-v8-draft-write-regression.md) | | [#306](https://github.com/Matysh/houseplan-card/issues/306) Нулевые стены вместо виртуальных границ | [306-zero-thickness-walls.md](306-zero-thickness-walls.md) | | [#348](https://github.com/Matysh/houseplan-card/issues/348) Полная немецкая локализация | [348-german-localization.md](348-german-localization.md) | +| [#428](https://github.com/Matysh/houseplan-card/issues/428) Round-trip экспорта с отсутствующей картинкой декора | [428-missing-decor-asset-roundtrip.md](428-missing-decor-asset-roundtrip.md) | ## P2