mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user