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
151 lines
8.3 KiB
Python
151 lines
8.3 KiB
Python
"""#446: golden-документы конвертера Sweet Home 3D против настоящей схемы.
|
||
|
||
Вторая половина цепочки против дрейфа версий. Конвертер живёт в репозитории и
|
||
раздаётся страницей `/convert` на сайте; между сайтом и моделью плана нет
|
||
ничего, кроме этого гейта. Поэтому golden проверяются не «структурой», а тем
|
||
самым `CONFIG_SCHEMA` и тем самым `commit_wall_segment_model`, которые
|
||
исполняются при импорте: правка модели, не отражённая в конвертере, краснеет
|
||
здесь, до того как это увидит пользователь.
|
||
|
||
Тест чистый — без Home Assistant. Полный путь предпросмотра требует HA и живёт
|
||
в `test_ha_sh3d_convert.py`.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from custom_components.houseplan.const import EXPORT_VERSION, PLAN_MODEL_VERSION
|
||
from custom_components.houseplan.coordinate_canonicalization import (
|
||
canonicalize_lattice_coordinate,
|
||
)
|
||
from custom_components.houseplan.validation import CONFIG_SCHEMA
|
||
from custom_components.houseplan.wall_segment_model import (
|
||
WALL_SEGMENT_MODEL_VERSION,
|
||
_wall_key,
|
||
commit_wall_segment_model,
|
||
)
|
||
|
||
REPO = Path(__file__).resolve().parent.parent
|
||
GOLDEN = REPO / "scripts" / "sh3d-convert" / "golden"
|
||
CONVERT = REPO / "scripts" / "sh3d-convert" / "convert.mjs"
|
||
|
||
|
||
def 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 _source_constant(name: str) -> str:
|
||
"""Значение константы из конвертера: расхождение обязано быть видно здесь."""
|
||
match = re.search(rf"^export const {name} = (.+);$", CONVERT.read_text(encoding="utf-8"),
|
||
re.MULTILINE)
|
||
assert match, f"в конвертере нет константы {name}"
|
||
return match.group(1).strip().strip("'\"")
|
||
|
||
|
||
@pytest.mark.parametrize(("name", "document"), documents())
|
||
def test_issue_446_envelope_matches_the_import_contract(name: str, document: dict) -> None:
|
||
"""Конверт документа — ровно то, что требует `parse_document`."""
|
||
assert document["format"] == "houseplan-export", name
|
||
assert document["export_version"] in (1, EXPORT_VERSION), name
|
||
assert document["kind"] == "space", name
|
||
assert 0 <= document["model_version"] <= PLAN_MODEL_VERSION, name
|
||
assert document["transfer"] == {"plan_only": True}, name
|
||
assert document["placement_manifest"] == [], name
|
||
assert document["content_manifest"] == [], name
|
||
assert document["payload"]["layout"] == {}, name
|
||
config = document["payload"]["config"]
|
||
assert config["markers"] == [], name
|
||
assert len(config["spaces"]) == 1, "plan-only требует ровно одно пространство"
|
||
|
||
|
||
def test_issue_446_converter_declares_a_version_the_import_accepts() -> None:
|
||
"""Модель документа не должна обогнать установку.
|
||
|
||
Конвертер объявляет форму v7 сознательно: структуру (сегменты, wall_ids,
|
||
хосты проёмов) собирает серверный писатель импорта. Значение больше
|
||
`PLAN_MODEL_VERSION` импорт отвергнет как `future_model`.
|
||
"""
|
||
declared = int(_source_constant("MODEL_VERSION"))
|
||
assert declared <= PLAN_MODEL_VERSION
|
||
assert declared < WALL_SEGMENT_MODEL_VERSION, (
|
||
"форма v7 выбрана намеренно: при v8+ схема требует полный каталог сегментов,"
|
||
" и конвертер обязан был бы повторить серверный алгоритм"
|
||
)
|
||
assert int(_source_constant("EXPORT_VERSION")) in (1, EXPORT_VERSION)
|
||
|
||
|
||
@pytest.mark.parametrize(("name", "document"), documents())
|
||
def test_issue_446_golden_passes_the_real_config_schema(name: str, document: dict) -> None:
|
||
config = dict(document["payload"]["config"])
|
||
config["model_version"] = document["model_version"]
|
||
validated = CONFIG_SCHEMA(json.loads(json.dumps(config)))
|
||
space = validated["spaces"][0]
|
||
assert space["rooms"], name
|
||
assert "wall_segments" not in space, "форма v7 не несёт каталога сегментов"
|
||
|
||
|
||
@pytest.mark.parametrize(("name", "document"), documents())
|
||
def test_issue_446_server_writer_turns_the_document_into_a_valid_plan(
|
||
name: str, document: dict,
|
||
) -> None:
|
||
"""То, что делает apply: миграция v7 → v9 и повторная проверка схемой."""
|
||
config = dict(document["payload"]["config"])
|
||
config["model_version"] = document["model_version"]
|
||
migrated, _ = commit_wall_segment_model(CONFIG_SCHEMA(json.loads(json.dumps(config))))
|
||
assert migrated["model_version"] == WALL_SEGMENT_MODEL_VERSION
|
||
space = migrated["spaces"][0]
|
||
segments = space["wall_segments"]
|
||
assert segments, name
|
||
for room in space["rooms"]:
|
||
assert len(room["wall_ids"]) == len(room["poly"]), "по идентификатору на ребро"
|
||
owners: dict[str, int] = {segment["id"]: 0 for segment in segments}
|
||
for room in space["rooms"]:
|
||
for segment_id in room["wall_ids"]:
|
||
owners[segment_id] += 1
|
||
assert all(1 <= count <= 2 for count in owners.values()), "сегмент без комнаты недопустим"
|
||
CONFIG_SCHEMA(json.loads(json.dumps(migrated)))
|
||
|
||
|
||
def test_issue_446_shared_boundary_becomes_one_segment_and_doors_get_hosts() -> None:
|
||
"""Ради этого конвертер и выравнивает вершины по осевым линиям стен."""
|
||
document = json.loads((GOLDEN / "flat-two-rooms.space-1.json").read_text(encoding="utf-8"))
|
||
config = dict(document["payload"]["config"])
|
||
config["model_version"] = document["model_version"]
|
||
migrated, _ = commit_wall_segment_model(CONFIG_SCHEMA(json.loads(json.dumps(config))))
|
||
space = migrated["spaces"][0]
|
||
assert len(space["wall_segments"]) == 7, "восемь рёбер, общая граница склеена в одно"
|
||
shared = [
|
||
segment for segment in space["wall_segments"]
|
||
if sum(segment["id"] in room["wall_ids"] for room in space["rooms"]) == 2
|
||
]
|
||
assert len(shared) == 1, "общая стена двух комнат обязана быть одним сегментом"
|
||
hosts = [opening.get("host") for opening in space["openings"]]
|
||
assert all(isinstance(host, dict) and host.get("kind") == "wall" for host in hosts), (
|
||
f"сервер обязан сам привязать проёмы к стенам, получено {hosts}"
|
||
)
|
||
|
||
|
||
def test_issue_446_ported_formulas_match_python() -> None:
|
||
"""Те же значения закреплены в test/sh3d-convert.test.mjs.
|
||
|
||
Формулы `_wall_key` и канонизации портированы в JS. Если они разойдутся,
|
||
сервер не найдёт толщину для рёбер, и стены приедут нулевыми — молча.
|
||
Расхождение краснит один из двух тестов.
|
||
"""
|
||
assert _wall_key([0.25, 0.3333333333333333], [0.5, 0.3333333333333333]) == (
|
||
"0.375000,0.333333@0.0000")
|
||
assert _wall_key([0.5, 0.3333333333333333], [0.5, 0.6666666666666666]) == (
|
||
"0.500000,0.500000@1.5706")
|
||
# 0.2499999 отличает две ветки канонизации: сваливание шума в узел даёт
|
||
# 0.25, а простое округление до девяти знаков — 0.2499999. Значение с
|
||
# одиннадцатью девятками (первая редакция теста) обе ветки давали одинаково,
|
||
# то есть пин ничего не проверял.
|
||
assert canonicalize_lattice_coordinate(0.2499999) == 0.25
|
||
assert canonicalize_lattice_coordinate(0.123456789012) == 0.123456789
|