From 51e1643762917f362fba14ffc3559766f173a484 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:56:58 +0300 Subject: [PATCH] =?UTF-8?q?tests=5Fbackend:=20=D0=B1=D0=B5=D0=B7=20HA=20py?= =?UTF-8?q?test=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D0=BD=D0=B5=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20test=5Fha=5F*.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conftest.py без Home Assistant исключает test_ha_*.py через collect_ignore_glob — это не skip, в итоговой строке их нет вовсе. Теперь шапка и итог прогона печатают «HA harness NOT collected: N files (M tests)» со ссылкой на канон (Linux CI / WSL scripts/wsl-setup.sh --verify). N и M считаются при каждом запуске по тому же glob. Тест test_conftest_harness_notice.py проверяет обе ветки conftest в отдельном процессе pytest (homeassistant перекрыт заглушкой), счёт по временному каталогу и по реальному харнессу против AST-счёта. Мутанты ha-harness-notice-silent, ha-harness-notice-count-frozen. TESTING.md и AGENTS.md: «silently skips» заменено точной формулировкой. Issue: #630 User-Visible: no --- AGENTS.md | 8 +- docs/TESTING.md | 14 +- scripts/mutation-registry.mjs | 22 +++ tests_backend/conftest.py | 59 ++++++- tests_backend/test_conftest_harness_notice.py | 149 ++++++++++++++++++ 5 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 tests_backend/test_conftest_harness_notice.py diff --git a/AGENTS.md b/AGENTS.md index 0d79dd9d..8b07e869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -493,9 +493,11 @@ sets it. **Backend.** A full Home Assistant harness cannot run on native Windows at all: Home Assistant imports the Unix-only `fcntl` module. Its canon is Linux CI or WSL. Locally only the pure subset runs; `python -m pytest tests_backend/ -q` without -Home Assistant **silently skips** `test_ha_*.py` (`conftest.py` ignores them when -`homeassistant` is not importable), so a green result proves nothing. Say so in the -report instead of claiming the backend was verified. Cloud agents have the harness +Home Assistant **does not collect** `test_ha_*.py` at all (`conftest.py` +`collect_ignore_glob` when `homeassistant` is not importable — not a skip) and, since +#630, prints `HA harness NOT collected: N files (M tests)` with the canon to run +them. A green result there proves nothing about the harness. Say so in the report +instead of claiming the backend was verified. Cloud agents have the harness at `.venv-backend/bin/python`. **Running the app / smoke suite**: build a fresh bundle and copy it into the demo diff --git a/docs/TESTING.md b/docs/TESTING.md index aca8c8f4..34618d2b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -215,6 +215,18 @@ manifest читают и `classify-changes.mjs` (job `changes`: job запуск доказывается unit/smoke-счётчиками и состоянием runtime, а не regex по исходнику (#440). +Без установленного Home Assistant (нативная Windows, песочница) HA-харнесс +`tests_backend/test_ha_*.py` **не собирается вовсе** — `conftest.py` исключает +эти файлы через `collect_ignore_glob`, поэтому их нет ни в `passed`, ни в +`skipped`, и зелёная итоговая строка про них ничего не говорит. С #630 pytest в +таком прогоне печатает в шапке и в итоге строку `HA harness NOT collected: N +test_ha_*.py files (M tests)` со ссылкой на канон — Linux CI или WSL +(`bash scripts/wsl-setup.sh --verify`). N и M считаются при каждом запуске по +тому же glob (объявления `def test_*`/`async def test_*`, без размножения +параметризацией), в документах их не переписывают. Проверка — +`tests_backend/test_conftest_harness_notice.py`, мутанты +`ha-harness-notice-silent` и `ha-harness-notice-count-frozen`. + ## PDF export polish (#482) - [ ] Нормализация контура сначала схлопывает соседние и шовные дубли в @@ -2441,7 +2453,7 @@ separately promised workflows: - [ ] Browser console has zero errors from houseplan-card.js on: dashboard load, markup, dialogs, zoom - [ ] HA log has zero houseplan errors/warnings after restart -- [ ] `npm test` (frontend), `pytest tests_backend` (pure), CI HA-harness — all green +- [ ] `npm test` (frontend), `pytest tests_backend` (pure only without HA: `test_ha_*.py` are not collected, the run prints how many — #630), CI HA-harness — all green - [ ] README screenshots/GIF still match the current UI (synthetic home only) --- diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 895e593c..a990e4b3 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -2880,6 +2880,28 @@ const MUTANT_DEFINITIONS = [ replace: ' pass', }], }, + { + id: 'ha-harness-notice-silent', + guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider', + because: 'without Home Assistant test_ha_*.py are not collected at all; a run that ' + + 'does not say so in its -q summary looks like a full backend check (#630 AC1)', + patches: [{ + file: 'tests_backend/conftest.py', + find: ' terminalreporter.write_line(ha_harness_warning(*HA_HARNESS_IGNORED), yellow=True)', + replace: ' pass', + }], + }, + { + id: 'ha-harness-notice-count-frozen', + guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider', + because: 'the number of ignored HA tests must follow the files, not a figure copied ' + + 'from one audit — a frozen count drifts the day a harness test is added (#630 AC1)', + patches: [{ + file: 'tests_backend/conftest.py', + find: ' tests = sum(len(_TEST_DEF.findall(path.read_text(encoding="utf-8"))) for path in files)', + replace: ' tests = 292', + }], + }, { id: 'device-echo-keeps-local-noncanonical', guard: 'node demo/smoke_device_position_history.mjs', diff --git a/tests_backend/conftest.py b/tests_backend/conftest.py index 2d8600c5..356d6d08 100644 --- a/tests_backend/conftest.py +++ b/tests_backend/conftest.py @@ -1,6 +1,10 @@ -"""Shared test config. HA-harness tests (test_ha_*.py) are skipped when -homeassistant is not installed: the local sandbox has Python 3.10, CI runs them -on 3.13 with pytest-homeassistant-custom-component.""" +"""Shared test config. HA-harness tests (test_ha_*.py) are NOT collected when +homeassistant is not installed (collect_ignore_glob, not a skip); pytest then +prints how many files/tests were left out (#630). The local sandbox has no HA, +CI runs them on 3.13 with pytest-homeassistant-custom-component.""" + +import re +from pathlib import Path try: import homeassistant # noqa: F401 @@ -10,6 +14,53 @@ except ImportError: collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"] +# Без Home Assistant файлы test_ha_*.py не собираются вовсе (это не skip: в +# итоговой строке pytest их нет ни в passed, ни в skipped), и зелёный локальный +# прогон выглядел проверкой всего backend (#630). Поэтому число несобранного +# считается здесь же, по тому же glob, и печатается в шапке и в итоге прогона. +# Счёт динамический: новый HA-файл или тест меняет число без правки этого файла. +HA_HARNESS_GLOB = "test_ha_*.py" +_TEST_DEF = re.compile(r"^[ \t]*(?:async[ \t]+)?def[ \t]+test_\w*[ \t]*\(", re.MULTILINE) + + +def ha_harness_inventory(directory: Path) -> tuple[int, int]: + """(файлов, тестовых функций) под HA_HARNESS_GLOB в каталоге ``directory``. + + Тест — это объявление ``def test_*``/``async def test_*`` на любом уровне + вложенности (методы классов тоже); параметризация не размножает число — + считается то, что написано в исходнике, а не то, что соберёт pytest. + """ + files = sorted(directory.glob(HA_HARNESS_GLOB)) + tests = sum(len(_TEST_DEF.findall(path.read_text(encoding="utf-8"))) for path in files) + return len(files), tests + + +def ha_harness_warning(files: int, tests: int) -> str: + return ( + f"HA harness NOT collected: {files} test_ha_*.py files ({tests} tests) " + "were ignored because homeassistant is not importable; a green result " + "here does not cover them. Canon: Linux CI or WSL " + "(bash scripts/wsl-setup.sh --verify)." + ) + + +HA_HARNESS_IGNORED = None if HAS_HA else ha_harness_inventory(Path(__file__).resolve().parent) + + +def pytest_report_header(config): + if HA_HARNESS_IGNORED is None: + return None + return ha_harness_warning(*HA_HARNESS_IGNORED) + + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + # Шапку `-q` не печатает, итог печатается всегда — предупреждение в нём + # видно и в коротком прогоне `python -m pytest tests_backend -q`. + if HA_HARNESS_IGNORED is None: + return + terminalreporter.write_sep("=", "HA harness not collected", yellow=True, bold=True) + terminalreporter.write_line(ha_harness_warning(*HA_HARNESS_IGNORED), yellow=True) + # Пакет интеграции без Home Assistant не импортируется: его __init__.py тянет # весь HA. Чистым тестам нужны только подмодули, поэтому в окружении БЕЗ HA # родительские пакеты подменяются пустышками с настоящим __path__ — подмодули @@ -28,7 +79,6 @@ collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"] if not HAS_HA: import sys import types - from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent for _name, _path in ( @@ -42,7 +92,6 @@ if not HAS_HA: if HAS_HA: import shutil - from pathlib import Path import pytest diff --git a/tests_backend/test_conftest_harness_notice.py b/tests_backend/test_conftest_harness_notice.py new file mode 100644 index 00000000..98eb8a97 --- /dev/null +++ b/tests_backend/test_conftest_harness_notice.py @@ -0,0 +1,149 @@ +"""#630: без Home Assistant conftest не собирает test_ha_*.py — и обязан сказать +об этом в выводе pytest, с числом файлов и тестов. + +Чистый тест: Home Assistant не нужен. Обе ветки conftest (есть HA / нет HA) +проверяются в отдельном процессе pytest над временным каталогом, где +``homeassistant`` подменён модулем-заглушкой на PYTHONPATH; поэтому результат +не зависит от того, установлен ли HA в текущем окружении (CI, WSL, Windows). +""" +from __future__ import annotations + +import ast +import importlib.util +import os +import shutil +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CONFTEST = HERE / "conftest.py" + + +def _load_conftest(): + # По пути и под своим именем: модуль `conftest`, уже загруженный pytest, + # не трогается. Ветка без HA ставит пустышки пакета интеграции только если + # их ещё нет — повторное исполнение ничего не меняет. + spec = importlib.util.spec_from_file_location("hp_conftest_under_test", CONFTEST) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _ast_count(paths: list[Path]) -> int: + """Независимый счёт: те же объявления, но через AST, а не регулярку.""" + total = 0 + for path in paths: + tree = ast.parse(path.read_text(encoding="utf-8")) + total += sum( + 1 for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name.startswith("test_") + ) + return total + + +FAKE_HA_ALPHA = '''\ +def test_one(): + pass + + +async def test_two(): + pass +''' + +FAKE_HA_BETA = '''\ +class TestGroup: + def test_three(self): + pass + + async def test_four(self): + pass + + +def helper_test_not_counted(): + pass +''' + +PLAIN = '''\ +def test_plain(): + assert True +''' + + +def _project(tmp_path: Path, *, ha_importable: bool) -> tuple[Path, dict[str, str]]: + project = tmp_path / "project" + tests = project / "tests_backend" + tests.mkdir(parents=True) + shutil.copyfile(CONFTEST, tests / "conftest.py") + (tests / "test_ha_alpha.py").write_text(FAKE_HA_ALPHA, encoding="utf-8") + (tests / "test_ha_beta.py").write_text(FAKE_HA_BETA, encoding="utf-8") + (tests / "test_plain.py").write_text(PLAIN, encoding="utf-8") + (project / "pytest.ini").write_text("[pytest]\n", encoding="utf-8") + shim = tmp_path / "shim" + shim.mkdir() + # Заглушка стоит на PYTHONPATH раньше site-packages и перекрывает настоящий + # homeassistant, если он установлен. + (shim / "homeassistant.py").write_text( + "" if ha_importable else "raise ImportError('homeassistant hidden by #630 test')\n", + encoding="utf-8") + env = dict(os.environ) + env["PYTHONPATH"] = str(shim) + env["PYTEST_DISABLE_PLUGIN_AUTOLOAD"] = "1" + env.pop("PYTEST_ADDOPTS", None) + return project, env + + +def _pytest(project: Path, env: dict[str, str], *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "pytest", "tests_backend", "-p", "no:cacheprovider", + "--rootdir", str(project), "-c", str(project / "pytest.ini"), *args], + cwd=project, env=env, capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=120, check=False) + + +def test_inventory_counts_files_and_every_test_declaration(tmp_path): + conftest = _load_conftest() + (tmp_path / "test_ha_alpha.py").write_text(FAKE_HA_ALPHA, encoding="utf-8") + (tmp_path / "test_ha_beta.py").write_text(FAKE_HA_BETA, encoding="utf-8") + (tmp_path / "test_plain.py").write_text(PLAIN, encoding="utf-8") + assert conftest.ha_harness_inventory(tmp_path) == (2, 4) + + +def test_inventory_of_the_real_harness_matches_an_independent_count(): + conftest = _load_conftest() + files = sorted(HERE.glob("test_ha_*.py")) + assert files, "в tests_backend нет ни одного test_ha_*.py — glob разошёлся с раскладкой" + assert conftest.ha_harness_inventory(HERE) == (len(files), _ast_count(files)) + + +def test_without_ha_the_output_names_what_was_not_collected(tmp_path): + project, env = _project(tmp_path, ha_importable=False) + result = _pytest(project, env, "-q") + output = result.stdout + result.stderr + assert result.returncode == 0, output + expected = ("HA harness NOT collected: 2 test_ha_*.py files (4 tests) were ignored " + "because homeassistant is not importable") + assert expected in output, output + assert "bash scripts/wsl-setup.sh --verify" in output, output + # Строка стоит в итоге, а не только в шапке: `-q` шапку не печатает. + assert "1 passed" in output, output + + +def test_without_ha_the_header_names_it_too(tmp_path): + project, env = _project(tmp_path, ha_importable=False) + result = _pytest(project, env) + output = result.stdout + result.stderr + assert "collected 1 item" in output, output + header = output.split("collected 1 item", 1)[0] + assert "HA harness NOT collected: 2 test_ha_*.py files (4 tests)" in header, output + + +def test_with_ha_the_harness_is_collected_and_nothing_is_announced(tmp_path): + project, env = _project(tmp_path, ha_importable=True) + result = _pytest(project, env, "-q", "--collect-only") + output = result.stdout + result.stderr + assert result.returncode == 0, output + assert "test_ha_alpha.py::test_one" in output, output + assert "test_ha_beta.py::TestGroup::test_four" in output, output + assert "HA harness NOT collected" not in output, output