mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Красный backend на dev — это два независимых дефекта, и ни один не был виден в диффе. Первый, 85 падений. scripts/dump-config-schema.py подменяет custom_components и custom_components.houseplan пустышками, чтобы прочитать схему без Home Assistant, и оставляет их в sys.modules навсегда. Вызывает его в том числе pytest: tests_backend/test_config_schema_manifest.py идёт первым по алфавиту. Дальше HA просил у загрузчика custom_components.houseplan, получал пустышку без async_setup и отказывался поднимать интеграцию — «No setup or config entry setup function defined». Каждый тест харнесса падал на _setup с «assert False», и ни один не намекал на причину: подмена работает для подмодулей, потому что __path__ у пустышки настоящий. Подмена не убрана — без неё скрипт не выполнит свою задачу. Она стала обратимой: sys.modules снимается до и возвращается после, включая отсутствие ключа. Обратимость закреплена тестом. Второй, 1 падение. test_furniture_flip_flags_survive_coordinate_ canonicalization_unchanged требовал CONFIG_SCHEMA(result) == result, но схема на минимальном конфиге достраивает markers и settings и приводит целые к float — тождества там нет и не было. Проверяется теперь неподвижная точка: повторная валидация не меняет канонический вид, а флаги её переживают. Диагностика шла через CI: в песочнице HA-харнесс не поднять, поэтому временная ветка experiment/389-diag печатала, что именно видит загрузчик. Она показала модуль без __file__ и без единого атрибута — namespace-подобную пустышку, — и это вывело на подмену. Issue: #389 User-Visible: no
110 lines
4.6 KiB
Python
110 lines
4.6 KiB
Python
"""#33: the committed schema manifest must stay fresh, and the lifecycle
|
|
fixtures must pass the real schema losslessly.
|
|
|
|
The dump script stubs the package parents itself, so this module runs both in
|
|
the sandbox (no homeassistant) and in CI.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def _load_dump_module():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"houseplan_dump_config_schema", REPO / "scripts" / "dump-config-schema.py")
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules["houseplan_dump_config_schema"] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _validation():
|
|
# Модуль берётся возвращённым значением, а не из sys.modules: подмена
|
|
# пакетов теперь обратима и после загрузки ключей там не остаётся (#389).
|
|
return _load_dump_module()._load_validation()
|
|
|
|
|
|
def test_issue_33_manifest_is_fresh_and_deterministic():
|
|
dump = _load_dump_module()
|
|
first = dump.build_manifest()
|
|
second = dump.build_manifest()
|
|
assert first == second, "the walker must be deterministic"
|
|
rendered = json.dumps(first, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
committed = (REPO / "scripts" / "config-schema.json").read_text(encoding="utf-8")
|
|
assert rendered == committed, (
|
|
"scripts/config-schema.json is stale — the schema changed; "
|
|
"run python3 scripts/dump-config-schema.py and commit the diff"
|
|
)
|
|
# AC1: the manifest is not a stub — it must cover the whole persisted shape.
|
|
assert len(first["fields"]) > 200
|
|
|
|
|
|
def _collect_paths(node, prefix=""):
|
|
paths = set()
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
paths |= _collect_paths(value, f"{prefix}.{key}" if prefix else key)
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
paths |= _collect_paths(item, f"{prefix}[]")
|
|
else:
|
|
paths.add(prefix)
|
|
return paths
|
|
|
|
|
|
def test_issue_33_lifecycle_fixtures_pass_the_schema_losslessly():
|
|
validation = _validation()
|
|
fixtures = REPO / "test" / "fixtures" / "config-lifecycle"
|
|
dropped_by_design = {"spaces[].aspect", "spaces[].segments"} # vol.Remove
|
|
for name in ("oldest-supported", "current", "future-fields"):
|
|
raw = json.loads((fixtures / f"{name}.json").read_text(encoding="utf-8"))
|
|
validated = validation.CONFIG_SCHEMA(copy.deepcopy(raw))
|
|
before = _collect_paths(raw)
|
|
after = _collect_paths(validated)
|
|
lost = before - after - dropped_by_design
|
|
assert not lost, f"{name}: validation silently dropped {sorted(lost)}"
|
|
|
|
|
|
def test_issue_33_future_fields_round_trip_exactly():
|
|
validation = _validation()
|
|
fixtures = REPO / "test" / "fixtures" / "config-lifecycle"
|
|
raw = json.loads((fixtures / "future-fields.json").read_text(encoding="utf-8"))
|
|
validated = validation.CONFIG_SCHEMA(copy.deepcopy(raw))
|
|
assert validated["future_root_field"] == {"kept": True}
|
|
assert validated["settings"]["future_setting"] == [1, 2, 3]
|
|
assert validated["spaces"][0]["future_space_field"] == "kept"
|
|
assert validated["spaces"][0]["settings"]["future_display"] is True
|
|
assert validated["markers"][0]["future_marker_field"] == {"nested": "kept"}
|
|
|
|
|
|
def test_issue_389_the_dump_leaves_sys_modules_as_it_found_it():
|
|
"""Подмена пакетов обязана быть обратимой.
|
|
|
|
Скрипт дампа подменяет `custom_components` и `custom_components.houseplan`
|
|
пустышками, чтобы прочитать схему без Home Assistant. Пока подмена
|
|
оставалась в `sys.modules`, HA получал пакет без `async_setup` и отказывался
|
|
поднимать интеграцию — 85 тестов харнесса падали с «assert False», и ни
|
|
один не указывал на причину. Тест держит именно обратимость, а не факт
|
|
подмены: без неё скрипт свою работу не сделает.
|
|
"""
|
|
keys = (
|
|
"custom_components",
|
|
"custom_components.houseplan",
|
|
"custom_components.houseplan.validation",
|
|
"custom_components.houseplan.coordinate_canonicalization",
|
|
)
|
|
missing = object()
|
|
before = {name: sys.modules.get(name, missing) for name in keys}
|
|
|
|
_validation()
|
|
|
|
for name in keys:
|
|
after = sys.modules.get(name, missing)
|
|
assert after is before[name], f"{name}: подмена не возвращена на место"
|