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