mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user