From 8b9548775f7ddd7b8d90c365b5abdd1bff0d2b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 11:06:25 +0300 Subject: [PATCH] =?UTF-8?q?test(backend):=20HA-=D0=B7=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=B8=D0=BC=D1=8B=D0=B9=20=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D1=8D?= =?UTF-8?q?=D1=82=D0=BE=20=D1=81=D0=B0=D0=BC,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BC=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- scripts/mutation-gate.mjs | 13 ++ tests_backend/test_backend_quality.py | 141 ++++++++++++++++++ .../test_coordinate_canonicalization.py | 20 +++ 3 files changed, 174 insertions(+) diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index cf45480d..047d2299 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/tests_backend/test_backend_quality.py b/tests_backend/test_backend_quality.py index dd17aad3..a610d685 100644 --- a/tests_backend/test_backend_quality.py +++ b/tests_backend/test_backend_quality.py @@ -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"} diff --git a/tests_backend/test_coordinate_canonicalization.py b/tests_backend/test_coordinate_canonicalization.py index 36c7027e..ad1d9361 100644 --- a/tests_backend/test_coordinate_canonicalization.py +++ b/tests_backend/test_coordinate_canonicalization.py @@ -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,