tests_backend: без HA pytest называет число несобранных test_ha_*.py

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
This commit is contained in:
Claude
2026-09-24 01:22:22 +00:00
committed by claude[bot]
parent 913420df68
commit 51e1643762
5 changed files with 243 additions and 9 deletions
+5 -3
View File
@@ -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
+13 -1
View File
@@ -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)
---
+22
View File
@@ -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',
+54 -5
View File
@@ -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
@@ -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