Files
houseplan-card/tests_backend/test_coordinate_canonicalization.py
2026-09-03 16:50:54 +03:00

137 lines
6.1 KiB
Python

"""Shared coordinate canonicalisation contract (#224)."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
# Этот файл требует Home Assistant, но называется не `test_ha_*` (#436).
#
# Отсекать HA-тесты по имени файла — конструкция, которая уже стоила #389:
# корректность харнесса держалась на именах в каталоге. Здесь она стоила
# меньшего, но обиднее: `collect_ignore_glob` в conftest этот файл не ловил,
# импорт `store` тянул `homeassistant`, и `python3 -m pytest tests_backend/`
# без HA падал НА СБОРКЕ. То есть чистое подмножество не выполнялось вовсе —
# ни один из 310 тестов, — а документация обещала обратное.
#
# `importorskip` на уровне модуля превращает это в честный скип: pytest
# сообщает «1 skipped» вместо «Interrupted», остальные файлы прогоняются. В
# CI Home Assistant установлен, поэтому там не скипается ничего.
#
# Импорты ниже стоят после этой строки намеренно: они и есть то, что без HA
# не выполнится. Порядок закреплён тестом
# `test_issue_436_ha_dependent_test_modules_declare_it` — он краснеет, если
# такой файл появится без объявления.
pytest.importorskip("homeassistant", reason="модуль тянет HA через store (#436)")
from custom_components.houseplan import virtual_lights
from custom_components.houseplan.coordinate_canonicalization import (
canonicalize_config_geometry,
canonicalize_layout_geometry,
)
from custom_components.houseplan.store import (
async_save_config_state,
async_save_layout_state,
)
from custom_components.houseplan.validation import (
CONFIG_SCHEMA,
LAYOUT_SCHEMA,
POS_SCHEMA,
)
from custom_components.houseplan.wall_segment_model import commit_wall_segment_model
FIXTURE = (
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "coordinate-canonicalization.json"
)
OPTIMIZE_ROUNDTRIP_FIXTURE = (
Path(__file__).parents[1]
/ "test"
/ "fixtures"
/ "optimize-storage-roundtrip.json"
)
def _fixture() -> dict:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def test_backend_schemas_apply_the_same_allowlist() -> None:
fixture = _fixture()
assert CONFIG_SCHEMA(fixture["configInput"]) == fixture["configExpected"]
assert LAYOUT_SCHEMA(fixture["layoutInput"]) == fixture["layoutExpected"]
assert POS_SCHEMA(fixture["layoutInput"]["virtual"]) == fixture["layoutExpected"]["virtual"]
def test_furniture_flip_flags_survive_coordinate_canonicalization_unchanged() -> None:
config = {"spaces": [{
"id": "s", "title": "S", "view_box": [0, 0, 1, 1], "rooms": [],
"decor": [{
"id": "f", "kind": "furniture", "symbol": "sofa",
"x": 0.2000000001, "y": 0.3, "w": 0.18, "h": 0.075,
"flip_h": True, "flip_v": False,
}],
}]}
result = canonicalize_config_geometry(config)
furniture = result["spaces"][0]["decor"][0]
assert furniture["flip_h"] is True
assert furniture["flip_v"] is False
# Схема не тождественна на минимальном конфиге: она достраивает `markers`
# и `settings` и приводит целые к float. Поэтому проверяется не равенство
# с входом, а неподвижная точка — канонический вид схемы не «плывёт» от
# повторной валидации, и флаги её переживают (#389).
validated = CONFIG_SCHEMA(result)
assert CONFIG_SCHEMA(validated) == validated
mirrored = validated["spaces"][0]["decor"][0]
assert mirrored["flip_h"] is True
assert mirrored["flip_v"] is False
def test_optimize_roundtrip_fixture_has_one_backend_canonical_target() -> None:
"""#248: Python consumes the same cross-runtime target as the Node test."""
fixture = json.loads(OPTIMIZE_ROUNDTRIP_FIXTURE.read_text(encoding="utf-8"))
source = fixture["input"]
expected = fixture["expected"]
assert all(space["view_box"] == [0, 0, 1, 1]
for space in source["config"]["spaces"])
canonical_source = canonicalize_config_geometry(source["config"])
migrated, _count = commit_wall_segment_model(canonical_source)
assert migrated == expected["config"]
assert canonicalize_layout_geometry(source["layout"]) == expected["layout"]
assert CONFIG_SCHEMA(source["config"]) == canonical_source
assert CONFIG_SCHEMA(migrated) == expected["config"]
assert LAYOUT_SCHEMA(source["layout"]) == expected["layout"]
@pytest.mark.asyncio
async def test_storage_helpers_are_the_final_canonical_barrier(monkeypatch) -> None:
fixture = _fixture()
reconcile = AsyncMock()
monkeypatch.setattr(virtual_lights, "async_reconcile_virtual_lights", reconcile)
config_store = AsyncMock()
config_store.async_load.return_value = {"rev": 0}
config_runtime = SimpleNamespace(
config_store=config_store,
virtual_light_store=object(),
)
payload = await async_save_config_state(
config_runtime, fixture["configInput"], 1, previous_rev=0
)
assert payload["config"] == fixture["configExpected"]
assert config_store.async_save.await_args.args[0] == payload
assert reconcile.await_args.args[1] == fixture["configExpected"]
layout_store = AsyncMock()
layout_runtime = SimpleNamespace(store=layout_store)
layout_payload = await async_save_layout_state(
layout_runtime, {}, fixture["layoutInput"], 1
)
assert layout_payload["layout"] == fixture["layoutExpected"]
assert layout_store.async_save.await_args.args[0] == layout_payload