diff --git a/test/backend-test-hygiene.test.mjs b/test/backend-test-hygiene.test.mjs index feca4371..29dfcd66 100644 --- a/test/backend-test-hygiene.test.mjs +++ b/test/backend-test-hygiene.test.mjs @@ -26,3 +26,26 @@ test('backend-тесты не правят sys.path (#393)', () => { + ' Читать модуль без Home Assistant можно и без этого — см. test_trails.py:' + ' текст файла и exec нужного среза.'); }); + +test('пакет интеграции подменяет только conftest и только без HA (#394)', () => { + // Пустышка вместо `custom_components.houseplan` — единственный способ читать + // подмодули без Home Assistant, и запретить её нельзя. Но место у неё одно: + // conftest, где решение принимается по честному признаку «есть ли HA». + // + // Когда её ставили сами тесты под условием «если ещё не импортирован», в CI + // она не срабатывала лишь потому, что настоящий пакет успевал импортироваться + // из файла, который идёт раньше по алфавиту. Корректность харнесса держалась + // на именах файлов; чем это кончается, показал #389. + const assigns = /sys\.modules\[\s*(['"])custom_components/; + const offenders = files().filter((name) => name !== 'conftest.py' && assigns.test(read(name))); + assert.deepEqual(offenders, [], + 'подмена пакета интеграции живёт в tests_backend/conftest.py и только там:' + + ' там она условная (нет Home Assistant — нечего ломать), а в тесте она' + + ' переживает свой тест и достаётся всей сессии (#389, #394).'); + + const conftest = read('conftest.py'); + assert.match(conftest, /if not HAS_HA:/, + 'подмена в conftest обязана быть под условием отсутствия Home Assistant'); + const stub = conftest.slice(conftest.indexOf('if not HAS_HA:')); + assert.match(stub, /sys\.modules\[_name\] = _module/); +}); diff --git a/tests_backend/conftest.py b/tests_backend/conftest.py index 8f52d8d4..e1f9aa99 100644 --- a/tests_backend/conftest.py +++ b/tests_backend/conftest.py @@ -10,6 +10,36 @@ except ImportError: collect_ignore_glob = [] if HAS_HA else ["test_ha_*.py"] +# Пакет интеграции без Home Assistant не импортируется: его __init__.py тянет +# весь HA. Чистым тестам нужны только подмодули, поэтому в окружении БЕЗ HA +# родительские пакеты подменяются пустышками с настоящим __path__ — подмодули +# после этого читаются, а тяжёлый __init__.py не исполняется. +# +# Подмена живёт здесь, а не в тестах, и это главное решение issue #394. Раньше +# её ставили сами тесты, под условием «если ещё не импортирован», и в CI она не +# срабатывала лишь потому, что настоящий пакет успевал импортироваться из файла, +# который идёт раньше по алфавиту. Корректность HA-харнесса держалась на именах +# файлов в каталоге; чем это кончается, показал #389 — 85 тестов упали с голым +# `assert False`, потому что HA получил пустышку вместо интеграции. +# +# Теперь развилка явная и по единственному честному признаку: есть Home +# Assistant — работаем с настоящим пакетом и ничего не подменяем; нет — значит +# HA-тесты и так пропущены, и подменять безопасно, ломать нечего. +if not HAS_HA: + import sys + import types + from pathlib import Path + + _ROOT = Path(__file__).resolve().parent.parent + for _name, _path in ( + ("custom_components", _ROOT / "custom_components"), + ("custom_components.houseplan", _ROOT / "custom_components" / "houseplan"), + ): + if _name not in sys.modules: + _module = types.ModuleType(_name) + _module.__path__ = [str(_path)] + sys.modules[_name] = _module + if HAS_HA: import shutil from pathlib import Path diff --git a/tests_backend/pure_imports.py b/tests_backend/pure_imports.py new file mode 100644 index 00000000..c4e0d6e9 --- /dev/null +++ b/tests_backend/pure_imports.py @@ -0,0 +1,33 @@ +"""Загрузка модуля интеграции по пути, без Home Assistant (issue #394). + +Зачем не обычный импорт: часть чистых тестов читает модуль под своим именем +(`hp_validation`) либо под каноническим, чтобы работали относительные импорты +внутри него. Родительские пакеты при этом должны существовать — в окружении с +Home Assistant это настоящий пакет, без него пустышки ставит `conftest.py`. + +Ставить пустышки здесь, в момент загрузки, было бы ошибкой: именно так они +переживали свой тест и доставались всей сессии. В #389 это стоило пяти часов +красного `dev` — HA получал пустышку вместо интеграции и не мог её поднять. +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +PACKAGE_ROOT = REPO / "custom_components" +HOUSEPLAN_ROOT = PACKAGE_ROOT / "houseplan" + + +def load_pure(name: str, file: Path): + """Загрузить модуль по пути под указанным именем. + + Имя значимо: относительные импорты внутри модуля резолвятся только тогда, + когда модуль знает, какому пакету принадлежит. + """ + spec = importlib.util.spec_from_file_location(name, file) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module diff --git a/tests_backend/test_junction_limits.py b/tests_backend/test_junction_limits.py index 35c826a2..02adc16c 100644 --- a/tests_backend/test_junction_limits.py +++ b/tests_backend/test_junction_limits.py @@ -6,38 +6,29 @@ fixtures to the TypeScript checks and to this module and demands the same verdict, because two implementations of one rule are worth nothing if they can disagree. """ -import importlib.util import json import os import subprocess -import sys -import types import pytest +from pathlib import Path + +from pure_imports import load_pure + _ROOT = os.path.dirname(os.path.dirname(__file__)) _PACKAGE_ROOT = os.path.join(_ROOT, "custom_components") _HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan") -if "custom_components" not in sys.modules: - package = types.ModuleType("custom_components") - package.__path__ = [_PACKAGE_ROOT] - sys.modules["custom_components"] = package -if "custom_components.houseplan" not in sys.modules: - package = types.ModuleType("custom_components.houseplan") - package.__path__ = [_HOUSEPLAN_ROOT] - sys.modules["custom_components.houseplan"] = package - -# Loaded under its canonical package name: the module imports the migration -# mirror relatively (`from .wall_segment_model import ...`), which only resolves -# when the module knows the package it belongs to. -_PATH = os.path.join(_HOUSEPLAN_ROOT, "junction_limits.py") -_spec = importlib.util.spec_from_file_location( - "custom_components.houseplan.junction_limits", _PATH, +# Каноническое имя обязательно: модуль импортирует зеркало миграции +# относительным путём (`from .wall_segment_model import ...`), а это резолвится +# только когда модуль знает свой пакет. Подмена родительских пакетов живёт +# ровно на время загрузки — почему именно так, см. tests_backend/pure_imports.py +# и #394. +jl = load_pure( + "custom_components.houseplan.junction_limits", + Path(_HOUSEPLAN_ROOT) / "junction_limits.py", ) -jl = importlib.util.module_from_spec(_spec) -sys.modules[_spec.name] = jl -_spec.loader.exec_module(jl) CELL = 5.0 diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 3abd772a..20925a66 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -10,28 +10,20 @@ import types import pytest import voluptuous as vol +from pathlib import Path + +from pure_imports import load_pure + _ROOT = os.path.dirname(os.path.dirname(__file__)) _PACKAGE_ROOT = os.path.join(_ROOT, "custom_components") _HOUSEPLAN_ROOT = os.path.join(_PACKAGE_ROOT, "houseplan") -# Keep this pure test independent of Home Assistant even though Python normally -# executes package __init__.py before resolving the validation submodule. -if "custom_components" not in sys.modules: - package = types.ModuleType("custom_components") - package.__path__ = [_PACKAGE_ROOT] - sys.modules["custom_components"] = package -if "custom_components.houseplan" not in sys.modules: - package = types.ModuleType("custom_components.houseplan") - package.__path__ = [_HOUSEPLAN_ROOT] - sys.modules["custom_components.houseplan"] = package - -_PATH = os.path.join( - _ROOT, - "custom_components", "houseplan", "validation.py", -) -_spec = importlib.util.spec_from_file_location("hp_validation", _PATH) -v = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(v) +# Тест обходится без Home Assistant, хотя Python выполнил бы __init__.py пакета +# прежде, чем добрался до validation. Подмена родительских пакетов живёт ровно +# на время загрузки: прежняя редакция ставила пустышки насовсем и не падала +# только потому, что настоящий пакет успевал импортироваться из файла, который +# идёт раньше по алфавиту (#394). См. tests_backend/pure_imports.py. +v = load_pure("hp_validation", Path(_HOUSEPLAN_ROOT) / "validation.py") def test_backend_model_version_matches_frontend_constant():