"""#42: backend quality contracts — error codes, mypy allowlist, noqa hygiene. Pure tests: no homeassistant import, runnable in any environment. """ from __future__ import annotations import importlib.util import json import re from pathlib import Path REPO = Path(__file__).resolve().parent.parent BACKEND = REPO / "custom_components" / "houseplan" # Loading const.py by file path with a standalone module name deliberately # avoids stubbing "custom_components"/"custom_components.houseplan" in # sys.modules: leftover ModuleType stand-ins poison the Home Assistant harness # running later in the same pytest process — HA's loader then sees a package # without async_setup and every harness test fails with "No setup or config # entry setup function defined" (the exact #389 incident, caused by the schema # dump script). const.py imports nothing, so no package context is needed. def _const(): spec = importlib.util.spec_from_file_location( "houseplan_quality_const", BACKEND / "const.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _emitted_codes() -> tuple[set[str], set[str]]: """All fixed codes and family prefixes actually emitted by the sources. #42 AC5 (spec rev 4-6): BOTH emission paths — send_error literals and the err.code sources (class attrs, MarkerControlError literals, the conditional source_error/attribute_error assignments, the literal tuple codes, the f-string families) — fail-closed for anything else. """ api = (BACKEND / "websocket_api.py").read_text(encoding="utf-8") validation = (BACKEND / "validation.py").read_text(encoding="utf-8") junction = (BACKEND / "junction_limits.py").read_text(encoding="utf-8") fixed: set[str] = set() fixed |= set(re.findall(r'send_error\(\s*[^,]+,\s*"([a-z0-9_]+)"', api)) fixed |= set(re.findall(r'^\s+code = "([a-z0-9_]+)"', validation, re.M)) fixed |= set(re.findall(r'MarkerControlError\(\s*\n?\s*"([a-z0-9_]+)"', validation)) for pair in re.findall( r'(?:source_error|attribute_error)\s*=\s*"([a-z0-9_]+)"\s*if[^\n]*\\\s*\n\s*else\s*"([a-z0-9_]+)"', validation): fixed |= set(pair) # The literal-tuple path (field, code, message) is parsed STRUCTURALLY, # not by naming the two known codes: a third tuple entry with a brand-new # code must land in `fixed` (and so fail the registry test if the code is # unregistered). Fail-closed: a tuple block whose string count is not a # multiple of three means the shape changed — refuse instead of guessing. for block in re.findall( r'for field, code, message in \(\n(.*?)\n\s*\):', validation, re.S): strings = re.findall(r'"([^"]*)"', block) if not strings or len(strings) % 3: raise AssertionError( "unrecognized (field, code, message) tuple shape in validation.py: " f"{len(strings)} string literals — update the AC5 scanner") fixed |= set(strings[1::3]) families: set[str] = set() families |= {match + "_" if not match.endswith("_") else match for match in re.findall(r'MarkerControlError\(\s*f"\{prefix\}_', validation) and {"value_badge", "value_source"} or set()} if re.search(r'MarkerControlError\(\s*f"\{prefix\}', validation): families |= {"value_badge_", "value_source_"} if re.search(r'f"junction_limit_\{', junction): families.add("junction_limit_") # fail-closed: a MarkerControlError call whose first argument is neither a # literal, a known variable, nor a known f-string pattern is a hole. for call in re.findall(r'(?= 10, ( f"{path.name}:{index}: a bare noqa hides a decision — add the reason") def test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it(): """`load_pure` не оставляет следов в `sys.modules` (#398 AC4). Статический гвард (`test/backend-test-hygiene.test.mjs`) разрешает этому файлу писать в `sys.modules`, потому что не видит очистки. Значит очистку обязан доказывать исполняемый тест — иначе разрешение стало бы дырой ровно того размера, что #389: пустышка переживала свой тест, Home Assistant не мог поднять интеграцию, и 85 тестов харнесса падали с голым `assert False`. Проверяется именно РАЗНИЦА, а не пустота: пустышки родительских пакетов ставит conftest, они принадлежат ему и остаются. """ import sys from tests_backend.pure_imports import HOUSEPLAN_ROOT, load_pure before = sorted(k for k in sys.modules if k.startswith("custom_components")) module = load_pure( "custom_components.houseplan.junction_limits", HOUSEPLAN_ROOT / "junction_limits.py", ) assert dir(module), "модуль обязан быть рабочим после очистки" after = sorted(k for k in sys.modules if k.startswith("custom_components")) assert after == before, ( f"load_pure оставил в sys.modules: {sorted(set(after) - set(before))} — " "относительные импорты подтягивают соседей, снимать нужно всю разницу" ) # Повторный вызов обязан работать так же: очистка не должна ломать # следующий заход (тесты вызывают load_pure по нескольку раз за сессию). again = load_pure( "custom_components.houseplan.junction_limits", HOUSEPLAN_ROOT / "junction_limits.py", ) assert dir(again) assert sorted(k for k in sys.modules if k.startswith("custom_components")) == before