No-HA end-to-end smoke test for the bundled core package (task 628473ad)
No-HA end-to-end smoke test for the bundled core package (task 628473ad)
This commit was merged in pull request #145.
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
"""No-HA end-to-end smoke test (task 628473ad).
|
||||||
|
|
||||||
|
The bundled core package (``custom_components.nestquest.core``) is
|
||||||
|
self-contained and Home-Assistant-free, but the parent integration
|
||||||
|
package ``custom_components.nestquest`` is NOT: its ``__init__.py``
|
||||||
|
imports ``homeassistant``. A genuine "core runs with no Home
|
||||||
|
Assistant installed" proof therefore cannot live inside the suite
|
||||||
|
process — the suite's ``conftest.py`` installs mock ``homeassistant``
|
||||||
|
modules globally before any test imports the integration, so an
|
||||||
|
in-process exercise can never distinguish "core never touched HA"
|
||||||
|
from "conftest already stubbed HA for us".
|
||||||
|
|
||||||
|
This module runs the smoke test in a SUBPROCESS (``sys.executable -c
|
||||||
|
<script>``) that starts with a clean ``sys.modules`` and no
|
||||||
|
``homeassistant`` installed. The subprocess script:
|
||||||
|
|
||||||
|
- asserts ``homeassistant`` is NOT in ``sys.modules`` before importing
|
||||||
|
core;
|
||||||
|
- installs a STUB parent package ``custom_components.nestquest`` (a
|
||||||
|
bare :class:`types.ModuleType` whose ``__path__`` points at the real
|
||||||
|
``custom_components/nestquest`` directory) into ``sys.modules``
|
||||||
|
WITHOUT executing its ``__init__.py`` — so importing
|
||||||
|
``custom_components.nestquest.core.<x>`` resolves the real core
|
||||||
|
subpackage without dragging the integration's HA imports along;
|
||||||
|
- installs a ``custom_components`` namespace entry too, so the
|
||||||
|
subpackage resolves;
|
||||||
|
- imports the core modules (db, migrations, children,
|
||||||
|
quest_definitions, materialize, completion, recurrence);
|
||||||
|
- asserts ``homeassistant`` is STILL not in ``sys.modules`` after
|
||||||
|
those imports;
|
||||||
|
- opens a FRESH SQLite file, applies migrations, creates two children
|
||||||
|
and one quest definition (a daily recurrence rule with a start date
|
||||||
|
and one ``morning`` window), runs :func:`materialize` over a 28-day
|
||||||
|
window, completes one instance via :func:`complete_instance`
|
||||||
|
(``actor_source='panel'``), and derives the instance state via
|
||||||
|
:func:`instance_state`, asserting it is ``done``;
|
||||||
|
- prints a clear success marker.
|
||||||
|
|
||||||
|
The pytest test asserts the subprocess exit code is 0 and the marker
|
||||||
|
is printed, and fails with the subprocess stderr otherwise. This
|
||||||
|
guards the storage/engines/business extraction (tasks d0b691d8 /
|
||||||
|
510c1f78 / 4dd8f870) from regressing into a HA-coupled bundle: the
|
||||||
|
moment a core module grows a ``homeassistant`` import (direct or via
|
||||||
|
the parent package's ``__init__``), this test fails in a process
|
||||||
|
where HA is genuinely absent.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
#: The marker the subprocess prints on success. Kept short and unique
|
||||||
|
#: so the parent test can assert it survived intact in stdout.
|
||||||
|
SUCCESS_MARKER = "NO-HA SMOKE TEST OK"
|
||||||
|
|
||||||
|
#: The repo root, so the subprocess can locate the real
|
||||||
|
#: ``custom_components/nestquest`` directory regardless of its own
|
||||||
|
#: working directory.
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
#: The smoke-test script, run with ``sys.executable -c``. It is a
|
||||||
|
#: real no-HA process: the suite's conftest stubs do not exist here,
|
||||||
|
#: and ``homeassistant`` is not installed in this venv, so any core
|
||||||
|
#: import of HA (direct or via the parent package ``__init__``) raises
|
||||||
|
#: ``ModuleNotFoundError`` and the subprocess exits non-zero.
|
||||||
|
_SCRIPT = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(sys.argv[1])
|
||||||
|
CC_PATH = REPO_ROOT / "custom_components"
|
||||||
|
NQ_PATH = CC_PATH / "nestquest"
|
||||||
|
|
||||||
|
# 1. homeassistant must not be imported before we touch core.
|
||||||
|
assert "homeassistant" not in sys.modules, (
|
||||||
|
"homeassistant already in sys.modules before core import — "
|
||||||
|
"the subprocess is not a clean no-HA process"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Stub the parent packages WITHOUT running their __init__.py.
|
||||||
|
# custom_components is a namespace package; nestquest's __init__
|
||||||
|
# imports homeassistant, so we install a bare ModuleType whose
|
||||||
|
# __path__ points at the real directory instead of executing it.
|
||||||
|
cc = types.ModuleType("custom_components")
|
||||||
|
cc.__path__ = [str(CC_PATH)]
|
||||||
|
sys.modules["custom_components"] = cc
|
||||||
|
|
||||||
|
nq = types.ModuleType("custom_components.nestquest")
|
||||||
|
nq.__path__ = [str(NQ_PATH)]
|
||||||
|
nq.__package__ = "custom_components.nestquest"
|
||||||
|
sys.modules["custom_components.nestquest"] = nq
|
||||||
|
cc.nestquest = nq
|
||||||
|
|
||||||
|
# 3. Import the core modules directly from the bundle.
|
||||||
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
from custom_components.nestquest.core.children import create_child
|
||||||
|
from custom_components.nestquest.core.quest_definitions import (
|
||||||
|
create_quest_definition,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.core.materialize import materialize
|
||||||
|
from custom_components.nestquest.core.completion import (
|
||||||
|
complete_instance,
|
||||||
|
instance_state,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.core.recurrence import (
|
||||||
|
RuleType,
|
||||||
|
ScheduleRule,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
|
QuestInstancesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. core must not have pulled in homeassistant.
|
||||||
|
assert "homeassistant" not in sys.modules, (
|
||||||
|
"homeassistant imported by core — the core bundle is no longer "
|
||||||
|
"Home-Assistant-free"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _executor(fn, *args, **kwargs):
|
||||||
|
return await asyncio.to_thread(fn, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
db_path = Path(tempfile.mkdtemp(prefix="nq-noha-smoke-")) / "nestquest.db"
|
||||||
|
database = NestQuestDatabase(_executor)
|
||||||
|
await database.open(str(db_path))
|
||||||
|
try:
|
||||||
|
await apply_migrations(database)
|
||||||
|
|
||||||
|
# Two children.
|
||||||
|
child_a = await create_child(database, "Ada")
|
||||||
|
child_b = await create_child(database, "Bee")
|
||||||
|
|
||||||
|
# One quest definition: daily recurrence, one morning window.
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.DAILY, interval=1, start_date=start_iso
|
||||||
|
)
|
||||||
|
created = await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Brush teeth",
|
||||||
|
rule,
|
||||||
|
[child_a.id, child_b.id],
|
||||||
|
["morning"],
|
||||||
|
)
|
||||||
|
assert created.definition.id is not None
|
||||||
|
|
||||||
|
# Materialize over a 28-day window (inclusive of both ends).
|
||||||
|
end_iso = (today + datetime.timedelta(days=27)).isoformat()
|
||||||
|
count = await materialize(database, start_iso, end_iso, today=today)
|
||||||
|
assert count > 0, (
|
||||||
|
f"materialize produced no instances over the 28-day "
|
||||||
|
f"window (count={count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Complete one instance for child_a on the start date.
|
||||||
|
instances = QuestInstancesDao(database)
|
||||||
|
rows = await instances.list_by_child_and_date(
|
||||||
|
child_a.id, start_iso
|
||||||
|
)
|
||||||
|
assert rows, (
|
||||||
|
"no materialized instance for child_a on the start date"
|
||||||
|
)
|
||||||
|
instance = rows[0]
|
||||||
|
|
||||||
|
result = await complete_instance(
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=child_a.id,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
assert result == "done", (
|
||||||
|
f"complete_instance returned {result!r}, expected 'done'"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = await instance_state(database, instance.id, today=today)
|
||||||
|
assert state == "done", (
|
||||||
|
f"instance_state returned {state!r}, expected 'done'"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
print("NO-HA SMOKE TEST OK")
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_runs_end_to_end_without_homeassistant() -> None:
|
||||||
|
"""Run the no-HA smoke test in a subprocess and assert its marker.
|
||||||
|
|
||||||
|
The subprocess is a genuine no-HA process: the suite's conftest
|
||||||
|
stubs are not present there, and ``homeassistant`` is not installed
|
||||||
|
in this venv. If any core module ever grows a ``homeassistant``
|
||||||
|
import (directly or via the parent package ``__init__``), the
|
||||||
|
subprocess exits non-zero with a ``ModuleNotFoundError`` and this
|
||||||
|
test fails with the subprocess's stderr.
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", _SCRIPT, str(_REPO_ROOT)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AssertionError(
|
||||||
|
f"no-HA smoke subprocess exited {result.returncode}.\n"
|
||||||
|
f"--- stdout ---\n{result.stdout}\n"
|
||||||
|
f"--- stderr ---\n{result.stderr}"
|
||||||
|
)
|
||||||
|
assert SUCCESS_MARKER in result.stdout, (
|
||||||
|
f"success marker {SUCCESS_MARKER!r} not printed by subprocess.\n"
|
||||||
|
f"--- stdout ---\n{result.stdout}\n"
|
||||||
|
f"--- stderr ---\n{result.stderr}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user