mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Решение владельца по итогам исследования: из чужих форматов планировок
работать имеет смысл только с .sh3d, и конвертер живёт на сайте, а не в
карточке. Продуктового кода задача не касается вовсе — документ импорта
не подписан и не привязан к инстансу, поэтому сторонний генератор это
легальный сценарий уже сегодня.
Этап 1 — всё, что должно жить в репозитории и проверяться в CI:
- scripts/sh3d-convert/{xml,zip}.mjs — читатели XML и zip без единой
зависимости, работают и в Node, и в браузере (DecompressionStream либо
node:zlib). Недоверенный ввод отбивается на входе: DOCTYPE
пропускается и не загружается, объявления сущностей отвергаются,
шифрованные записи, zip64 и распаковка сверх предела — отказ с кодом;
- sh3d.mjs — уровни, комнаты, стены, двери и окна в сантиметрах;
мебель, материалы, свет, камеры не читаются вовсе;
- convert.mjs — маппинг в документ kind=space, plan-only, model 7.
Форма v7 выбрана намеренно и это главное техническое решение задачи.
При v8+ схема требует полный каталог сегментов: wall_ids по числу рёбер,
один-два владельца у каждого сегмента, проекция walls, совпадающая с
сегментами. Всё это на стороне сайта означало бы повторить серверный
алгоритм и разойтись с ним на первом изменении модели. В форме v7 ту же
работу делает commit_wall_segment_model — тот же путь, которым едут
старые бэкапы: сегменты собираются сами, общая граница двух комнат
склеивается в один сегмент с двумя владельцами, проёмы получают хозяина.
Проверено прогоном: v7 → валидный v9.
Второе решение — план строится по комнатам. Стена в нашей модели
существует как ребро контура комнаты, поэтому стены Sweet Home 3D дают
рёбрам только толщину, а уровень без комнат конвертировать нечем: это
отказ с объяснением, а не пустой план.
Геометрия: вершины комнат привязываются к осевым линиям стен (Sweet Home
3D обводит комнаты по внутренним граням, «как есть» получились бы две
параллельные стены вместо общей), затем сваривются с точностью до
сантиметра. Проёмы проецируются на ребро, угол берётся у ребра, длина
обрезается до ребра — серверная привязка допускает 8° и 0.02 шага
решётки, поэтому ни угол, ни центр из файла доверия не заслуживают.
Гейт против дрейфа версий (AC5) — две половины:
- test/sh3d-convert.test.mjs: фикстуры → конвертер → сравнение с
закоммиченными golden. Правка конвертера без пересборки golden красная;
- tests_backend/test_sh3d_convert.py: golden проверяются настоящими
CONFIG_SCHEMA и commit_wall_segment_model, плюс кросс-рантаймовый пин
формул _wall_key и канонизации решётки. Правка модели, не отражённая в
конвертере, красная — до того, как это увидит пользователь;
- tests_backend/test_ha_sh3d_convert.py: golden проходят настоящий
create_preview (нужен HA, идёт в Linux CI). Там же отрицательная
проверка: документ с приватным полем обязан получить отказ.
Свидетели, все проверены отрицательным прогоном: снятое выравнивание
вершин, отключённая сварка, угол проёма из файла, непроецированный
центр, необрезанная длина, снятая обрезка толщины, объявленная модель
v9, разошедшийся порт _wall_key, правка golden руками, поднятая
PLAN_MODEL_VERSION, изменённая серверная формула, изменённая канонизация
— каждая краснит свой тест.
Две фикстуры пришлось усилить именно из-за таких прогонов: углы и центры
проёмов в первой редакции совпадали со стенами случайно, и мутации
проходили молча; появилась и фикстура с общей границей без стены и шумом
в доли сантиметра — иначе сварка вершин не исполнялась ни разу.
Фикстуры синтетические, собраны генератором по опубликованному формату:
настоящих .sh3d в сборке нет и взять их автоматически негде. Проверка на
реальном файле — ручная приёмка владельца, записана в issue.
Гейты: npm test 1867 tests, 1866 pass, 0 fail; pytest без HA 378 passed,
3 skipped.
Этап 2 (страница /convert на houseplan.tech, ru/en) — следующим шагом.
Issue: #446
User-Visible: no
72 lines
3.6 KiB
Python
72 lines
3.6 KiB
Python
"""#446: golden конвертера Sweet Home 3D проходят настоящий предпросмотр импорта.
|
|
|
|
Самое сильное звено гейта против дрейфа: здесь документ, который получает
|
|
пользователь со страницы `/convert`, скармливается той же функции
|
|
`create_preview`, что и загрузка через интерфейс карточки. Модуль требует Home
|
|
Assistant (импорт тянет `store`), поэтому файл назван `test_ha_*` и локально
|
|
пропускается — в Linux CI он исполняется.
|
|
|
|
Чистая половина проверок — в `test_sh3d_convert.py`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from custom_components.houseplan.import_export import ImportFailure, create_preview
|
|
|
|
GOLDEN = Path(__file__).resolve().parent.parent / "scripts" / "sh3d-convert" / "golden"
|
|
|
|
|
|
def golden_documents() -> list[tuple[str, dict]]:
|
|
files = sorted(GOLDEN.glob("*.space-*.json"))
|
|
assert files, "golden конвертера пропали"
|
|
return [(path.name, json.loads(path.read_text(encoding="utf-8"))) for path in files]
|
|
|
|
|
|
def _preview(document: dict, root: Path) -> dict:
|
|
return create_preview(
|
|
SimpleNamespace(instance_id="target-instance", import_previews={}),
|
|
json.dumps(document).encode(),
|
|
owner_id="alice",
|
|
duplicate_policy="skip",
|
|
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0},
|
|
current_layout_data={"layout": {}, "rev": 0},
|
|
config_root=root,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(("name", "document"), golden_documents())
|
|
def test_issue_446_import_preview_accepts_the_converted_document(
|
|
name: str, document: dict, tmp_path,
|
|
) -> None:
|
|
response = _preview(document, tmp_path / "target")
|
|
preview = response["preview"]
|
|
assert response["token"], name
|
|
assert preview["counts"]["spaces"] == 1, name
|
|
assert preview["counts"]["rooms"] >= 1, name
|
|
assert preview["counts"]["markers"] == 0, "конвертер не приносит устройств"
|
|
assert preview["content"] == [], "и не приносит файлов"
|
|
# Чужой источник — законное состояние: документ сделан не этой установкой.
|
|
assert "foreign_source" in preview["warnings"], name
|
|
assert "full_replaces_current" not in preview["warnings"], "этаж добавляется, не заменяет план"
|
|
|
|
|
|
def test_issue_446_plan_only_flag_survives_the_privacy_projection(tmp_path) -> None:
|
|
"""`plan_only: true` — обещание, которое сервер проверяет пересчётом.
|
|
|
|
Если конвертер положит в пространство поле вне разрешённого набора,
|
|
предпросмотр откажет с `invalid_format`. Тест держит это обещание честным:
|
|
подмена документа лишним полем обязана отказать.
|
|
"""
|
|
name, document = golden_documents()[0]
|
|
forged = json.loads(json.dumps(document))
|
|
forged["payload"]["config"]["spaces"][0]["plan_url"] = "/api/houseplan/content/plans/x.png"
|
|
with pytest.raises(ImportFailure):
|
|
_preview(forged, tmp_path / "forged")
|
|
# А неподделанный проходит — иначе предыдущая проверка ничего не значит.
|
|
assert _preview(document, tmp_path / "clean")["token"], name
|