test(backend): HA-зависимый тест объявляет это сам, а не именем файла

`python3 -m pytest tests_backend/` без Home Assistant обрывался НА СБОРКЕ:
`test_coordinate_canonicalization.py` тянет HA через `store`, а
`collect_ignore_glob` в conftest отсекает только `test_ha_*.py`. Ни один
из трёх сотен чистых тестов при этом не выполнялся, хотя CLAUDE.md и
PROCESS.md §8 обещают ровно обратное. В CI дефект невидим: там HA есть и
список игнора пуст.

Признак «нужен ли файлу HA» был подменён признаком «как файл назван» —
та же конструкция, которая в #389 уронила 85 тестов с голым assert False.

Вариант владельца — третий: `pytest.importorskip("homeassistant")` в
самом файле, до импортов, которые тянут HA. Теперь это честный скип
(«1 skipped» вместо «Interrupted»), остальные файлы прогоняются, а в CI
не скипается ничего.

Обещанная проверка остальных файлов сделана пофайловым collect: из
двадцати HA требует ровно один непоименованный — этот. Чтобы второй не
появился молча, добавлен статический гейт: он читает импорты (не
исполняет), строит множество модулей интеграции, тянущих HA, — с
замыканием по относительным импортам, потому что `import_export`
зависит от HA только через `store`, — и требует у такого теста либо имя
`test_ha_*`, либо importorskip.

Свидетели, каждый проверен отрицательным прогоном:
- снять importorskip → красный «файлам нужен HA, но они этого не
  объявляют»;
- новый чистый файл с импортом store → тот же красный;
- убрать замыкание → красный синтетический тест сканера;
- перестать исключать TYPE_CHECKING → красный он же;
- считать импорты внутри функций → красный он же.
Плюс два свидетеля самого сканера в теле гейта: `store` обязан быть
найден, `coordinate_canonicalization` обязан остаться чистым — иначе
«ничего не нашёл» выглядело бы как «всё в порядке».

Мутант `pure-backend-test-pulls-home-assistant` в реестре: добавляет
импорт store в чистый test_projection.py, guard — этот гейт.

Гейты: npm test 1791 tests, 1790 pass, 0 fail; pytest без HA
312 passed, 3 skipped (было: Interrupted, 0 выполнено);
mutation-gate --check зелёный.

Issue: #436
User-Visible: no
This commit is contained in:
Claude
2026-09-03 11:06:25 +03:00
parent bdf53dc482
commit 8b9548775f
3 changed files with 174 additions and 0 deletions
+13
View File
@@ -4771,6 +4771,19 @@ const MUTANT_DEFINITIONS = [
replace: ' if (row.url !== expectedUrl',
}],
},
{
id: 'pure-backend-test-pulls-home-assistant',
guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider',
because: 'a pure test module that imports an HA-dependent backend module must be caught '
+ 'statically: pytest aborts on collection, so ALL pure tests stop running and the '
+ 'output looks nothing like a normal failure (#436, the #389 pattern)',
patches: [{
file: 'tests_backend/test_projection.py',
find: 'import copy\nimport importlib.util\nimport os',
replace: 'import copy\nimport importlib.util\nimport os\n'
+ 'from custom_components.houseplan.store import async_save_config_state',
}],
},
{
id: 'benchmark-page-verdict-unwatched',
guard: 'node demo/guard/verify-guard.mjs',
+141
View File
@@ -196,3 +196,144 @@ def test_issue_398_pure_imports_leaves_sys_modules_as_it_found_it():
)
assert dir(again)
assert sorted(k for k in sys.modules if k.startswith("custom_components")) == before
# --- #436: файл, которому нужен Home Assistant, обязан это объявить ---------
def _toplevel_imports(path: Path) -> set[str]:
"""Модули, импортируемые на уровне модуля — то есть при сборке pytest.
Тела функций и классов не считаются: их импорты исполняются при вызове и
сборку не ломают. `if TYPE_CHECKING:` тоже не считается — этот блок не
исполняется никогда (`virtual_lights` так тянет `store` и остаётся чистым).
"""
import ast
names: set[str] = set()
def visit(body: list) -> None:
for node in body:
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
base = node.module or ""
if node.level: # from . import store / from .store import x
base = f".{base}" if base else "."
names.add(base)
names.update(f"{base}.{alias.name}".replace("..", ".", 1)
if base == "." else f"{base}.{alias.name}"
for alias in node.names)
elif isinstance(node, ast.If):
if "TYPE_CHECKING" in ast.unparse(node.test):
continue
visit(node.body)
visit(node.orelse)
elif isinstance(node, ast.Try):
visit(node.body)
for handler in node.handlers:
visit(handler.body)
visit(node.orelse)
visit(node.finalbody)
visit(ast.parse(path.read_text(encoding="utf-8")).body)
return names
def _ha_dependent_backend_modules(root: Path | None = None) -> set[str]:
"""Модули интеграции, которые при импорте тянут Home Assistant.
Прямые импортёры плюс замыкание по относительным импортам: `store` тянет HA
сам, а всё, что импортирует `store` на уровне модуля, тянет его через него.
"""
deps: dict[str, set[str]] = {}
for path in sorted((root or BACKEND).glob("*.py")):
imports = _toplevel_imports(path)
deps[path.stem] = {
name.lstrip(".").split(".")[0] for name in imports if name.startswith(".")
}
if any(name.split(".")[0] == "homeassistant" for name in imports):
deps[path.stem].add("homeassistant")
needs = {name for name, own in deps.items() if "homeassistant" in own}
changed = True
while changed: # обычно один проход; цикл — на случай цепочек длиннее одной
changed = False
for name, own in deps.items():
if name not in needs and own & needs:
needs.add(name)
changed = True
return needs
def _needs_home_assistant(path: Path, ha_modules: set[str]) -> bool:
"""Оборвётся ли импорт этого файла без Home Assistant."""
imports = _toplevel_imports(path)
if {name.split(".")[0] for name in imports} & {
"homeassistant", "pytest_homeassistant_custom_component",
}:
return True
touched = set()
for name in imports:
parts = name.split(".")
if parts[:2] == ["custom_components", "houseplan"] and len(parts) > 2:
touched.add(parts[2])
return bool(touched & ha_modules)
def test_issue_436_ha_dependent_test_modules_declare_it():
"""Сборка pytest не имеет права падать из-за отсутствия Home Assistant.
До #436 `conftest.py` отсекал HA-тесты по шаблону имени `test_ha_*.py`, а
`test_coordinate_canonicalization.py` тянул HA через `store` и под шаблон
не попадал: `python3 -m pytest tests_backend/` без HA обрывался на сборке,
и НИ ОДИН из трёх сотен чистых тестов не выполнялся. Признак «нужен ли
файлу HA» был подменён признаком «как файл назван» — та же конструкция,
которая в #389 уронила 85 тестов с голым `assert False`.
Проверка статическая и работает в любом окружении: она читает импорты, а
не исполняет их.
"""
tests_dir = Path(__file__).resolve().parent
ha_modules = _ha_dependent_backend_modules()
# Свидетели самого сканера: без них «ничего не нашёл» выглядело бы как
# «всё в порядке» — ровно тот вид зелёного, против которого заведён #430.
assert "store" in ha_modules, "сканер не видит прямого импортёра HA"
assert "coordinate_canonicalization" not in ha_modules, "чистый модуль помечен как HA"
ha_named = sorted(path for path in tests_dir.glob("test_ha_*.py"))
assert ha_named, "HA-тесты исчезли — у сканера не осталось положительных примеров"
for path in ha_named:
assert _needs_home_assistant(path, ha_modules), (
f"{path.name}: сканер не распознаёт HA-зависимость там, где она заведомо есть"
)
undeclared = [
path.name for path in sorted(tests_dir.glob("test_*.py"))
if _needs_home_assistant(path, ha_modules)
and not path.name.startswith("test_ha_")
and 'importorskip("homeassistant"' not in path.read_text(encoding="utf-8")
]
assert undeclared == [], (
"файлам нужен Home Assistant, но они этого не объявляют: "
f"{undeclared}. Либо имя test_ha_*.py — тогда их отсечёт conftest, — либо "
'pytest.importorskip("homeassistant") ДО импортов, тянущих HA (#436). '
"Иначе прогон tests_backend/ без HA оборвётся на сборке, и не выполнится "
"ни один тест, включая чистые."
)
def test_issue_436_scanner_follows_transitive_ha_dependencies(tmp_path):
"""Замыкание — не украшение: в интеграции есть модуль, зависящий от HA
только через соседа (`import_export` тянет его через `store`). Без
замыкания такой модуль считался бы чистым, и тест, импортирующий его,
снова обрывал бы сборку. Проверяется на синтетическом дереве, чтобы
свидетель не зависел от того, как перетасуют импорты в самой интеграции.
"""
(tmp_path / "direct.py").write_text("from homeassistant.core import HomeAssistant\n")
(tmp_path / "middle.py").write_text("from .direct import HomeAssistant\n")
(tmp_path / "far.py").write_text("from . import middle\n")
(tmp_path / "clean.py").write_text("import json\n")
(tmp_path / "lazy.py").write_text(
"from typing import TYPE_CHECKING\n"
"if TYPE_CHECKING:\n from .direct import HomeAssistant\n"
"def f():\n from .direct import HomeAssistant\n return HomeAssistant\n",
)
assert _ha_dependent_backend_modules(tmp_path) == {"direct", "middle", "far"}
@@ -9,6 +9,26 @@ 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 (
COORDINATE_DECIMALS,