diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index e3107b5..f34c276 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -50,7 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - runtime_data = hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, None) + domain_data = hass.data.setdefault(DOMAIN, {}) + runtime_data = domain_data.pop(entry.entry_id, None) + if not domain_data: + hass.data.pop(DOMAIN, None) if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) if runtime_data is not None: diff --git a/tests/conftest.py b/tests/conftest.py index 4b8dd32..3bf89cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,35 +7,222 @@ its tests), and its back-releases raise the floor to Python >=3.12 while this project supports >=3.11. The documented fallback is therefore used: ``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below. -The tests mock the ``homeassistant`` package via ``sys.modules`` injection; -this conftest wires the mock modules before any test module import and -provides the standard ``hass`` fixture shape (``config``, ``config_entries``, -``data``, ``loop``) that HA's own harness exposes. +conftest.py is the single place where ``homeassistant`` modules are provided: +it first tries to import the real packages (importlib in a try/except) and +only when they are absent installs MagicMock stand-ins via ``sys.modules`` and +wires the parent-package attributes. All of that happens here, before any +test module imports the integration, so no test module needs its own +``sys.modules`` setup. It also provides the ``hass`` fixture plus shared +helpers used by the behavior tests (config-entry factory, listener registry). """ from __future__ import annotations import asyncio +import importlib +import inspect import sys from types import SimpleNamespace from unittest.mock import MagicMock import pytest -# Mock homeassistant modules if not installed in the environment. Runs in -# conftest so it precedes every test module's import of the integration. -for _mod in ( +# --------------------------------------------------------------------------- +# Homeassistant module provisioning (mock-only fallback, real import first). +# --------------------------------------------------------------------------- +_HA_MODULES = ( "homeassistant", "homeassistant.core", "homeassistant.config_entries", "homeassistant.data_entry_flow", -): - sys.modules.setdefault(_mod, MagicMock()) +) -# Wire parent package attributes so "from homeassistant import config_entries" -# resolves to the mocked submodule, mirroring real HA's package layout. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] -sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] +_HA_AVAILABLE: dict[str, bool] = {} +for _mod in _HA_MODULES: + try: + importlib.import_module(_mod) + _HA_AVAILABLE[_mod] = True + except ImportError: + _HA_AVAILABLE[_mod] = False + +if not all(_HA_AVAILABLE.values()): + # Mock-only branch: install MagicMock stand-ins for the missing modules. + for _mod in _HA_MODULES: + if not _HA_AVAILABLE[_mod]: + sys.modules.setdefault(_mod, MagicMock()) + + # Wire parent package attributes so "from homeassistant import + # config_entries" resolves to the mocked submodule, mirroring real HA's + # package layout. + for _parent, _child in ( + ("homeassistant", "config_entries"), + ("homeassistant", "data_entry_flow"), + ("homeassistant", "core"), + ): + if not _HA_AVAILABLE[_parent]: + setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) + + +def _ha_mock(name: str): + """Return a module object for a mocked homeassistant module.""" + return sys.modules[name] + + +def options_flow_base(): + """Return the active OptionsFlowWithConfigEntry class (real or mocked).""" + return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry") + + +class _ConfigFlowBase: + """Stand-in for homeassistant.config_entries.ConfigFlow. + + A superset covering both the config-flow and options-flow tests: it must + satisfy every consumer of the mocked surface. + """ + + VERSION = 1 + + def __init_subclass__(cls, domain=None, **kwargs): + super().__init_subclass__(**kwargs) + cls._domain = domain + + def __init__(self): + self.context = {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_abort(self, *, reason, **kwargs): + return {"type": "abort", "reason": reason} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + async def async_set_unique_id(self, unique_id, raise_on_progress=True): + self.context["unique_id"] = unique_id + return None + + def _async_current_entries(self): + return self.hass.config_entries.async_entries(self._domain) + + def _async_in_progress(self): + return [] + + @staticmethod + def async_get_options_flow(config_entry): + raise NotImplementedError + + +class _OptionsFlowWithConfigEntryBase: + """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" + + def __init__(self, config_entry=None): + self.config_entry = config_entry + self.hass = None + self.options = dict(config_entry.options) if config_entry is not None else {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + +def _callback_decorator(fn): + """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" + fn.__ha_callback__ = True + return fn + + +if not all(_HA_AVAILABLE.values()): + # The concrete 2024.6-shaped surface the integration is written against + # (mirrors the shapes the tests previously installed per module). + _config_entries_mock = _ha_mock("homeassistant.config_entries") + _core_mock = _ha_mock("homeassistant.core") + _data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") + + _config_entries_mock.ConfigFlow = _ConfigFlowBase + _config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase + _core_mock.callback = _callback_decorator + _data_entry_flow_mock.RESULT_TYPE_FORM = "form" + _data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" + _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" + _data_entry_flow_mock.FlowResult = dict + + +# --------------------------------------------------------------------------- +# Shared helpers for the behavior tests. +# --------------------------------------------------------------------------- +class ListenerRegistry: + """Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers.""" + + def __init__(self, hass=None): + self._hass = hass + self._listeners: dict[str, list[object]] = {} + self.reloaded: list[str] = [] + self._loop = asyncio.new_event_loop() + + def add(self, entry_id: str, listener) -> object: + def _remove(): + self._listeners.get(entry_id, []).remove(listener) + + self._listeners.setdefault(entry_id, []).append(listener) + return _remove + + def dispatch_options_update(self, entry) -> None: + """Synchronous dispatch for sync tests (drives a private event loop).""" + for listener in list(self._listeners.get(entry.entry_id, [])): + result = listener(self._hass, entry) + if inspect.isawaitable(result): + self._loop.run_until_complete(result) + + async def async_dispatch_options_update(self, entry) -> None: + """Async dispatch for pytest-asyncio tests (awaits listeners in the running loop).""" + for listener in list(self._listeners.get(entry.entry_id, [])): + result = listener(self._hass, entry) + if inspect.isawaitable(result): + await result + + @property + def size(self) -> int: + return sum(len(listeners) for listeners in self._listeners.values()) + + +def make_config_entry( + entry_id: str = "test_entry", + options: dict | None = None, + data: dict | None = None, +): + """Create a stub config entry shaped like HA's ConfigEntry for tests.""" + entry = SimpleNamespace( + entry_id=entry_id, + options=dict(options or {}), + data=dict(data or {}), + ) + entry.add_update_listener = None + return entry + + +def wire_entry_to_registry(entry, registry: ListenerRegistry): + """Attach an update-listener registration function to the entry.""" + entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) + return entry + + +def make_hass() -> tuple: + """Create a hass stub plus its listener registry, mirroring HA's shape.""" + hass = SimpleNamespace( + data={}, + config=MagicMock(), + config_entries=SimpleNamespace(async_reload=None), + ) + registry = ListenerRegistry(hass) + + async def _async_reload(entry_id: str) -> None: + registry.reloaded.append(entry_id) + + hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) + return hass, registry @pytest.fixture @@ -43,16 +230,64 @@ def hass(): """Provide a standard-shaped hass fixture. Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config`` - namespace, a ``config_entries`` manager, a ``data`` dict for integration - state, and the running event ``loop``. + namespace, a ``config_entries`` manager wired to a listener registry, a + ``data`` dict for integration state, and the running event ``loop``. """ - hass = SimpleNamespace( - data={}, - config=MagicMock(), - config_entries=MagicMock(), - ) - hass.loop = asyncio.new_event_loop() + _hass, registry = make_hass() + _hass.registry = registry + _hass.loop = asyncio.new_event_loop() try: - yield hass + yield _hass finally: - hass.loop.close() \ No newline at end of file + _hass.loop.close() + + +@pytest.fixture +def make_entry(): + """Provide the config-entry factory as a fixture.""" + + def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): + return make_config_entry(entry_id=entry_id, options=options, data=data) + + return _factory + + +@pytest.fixture +def listener_registry(): + """Provide a fresh listener registry for manual wiring.""" + + def _factory(hass=None): + return ListenerRegistry(hass) + + return _factory + + +@pytest.fixture +def make_flow(): + """Provide a factory for a stubbed NestQuestConfigFlow bound to hass.""" + + def _factory(hass=None, existing_entries=None, in_progress=None): + from custom_components.nestquest import config_flow + + flow = config_flow.NestQuestConfigFlow() + entries = [ + e for e in (existing_entries or []) if getattr(e, "domain", None) == flow._domain + ] + flow.hass = SimpleNamespace( + config_entries=SimpleNamespace(async_entries=lambda domain: entries) + ) + if in_progress: + flow._async_in_progress = lambda: in_progress + return flow + + return _factory + + +@pytest.fixture +def make_config_flow_entry(): + """Provide an entry stub carrying a domain, for current-entries checks.""" + + def _factory(domain: str, entry_id: str = "existing"): + return SimpleNamespace(domain=domain, entry_id=entry_id) + + return _factory \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 46eaefb..40927ee 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,58 +4,6 @@ from __future__ import annotations import ast import json from pathlib import Path -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", - "homeassistant.data_entry_flow", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] -sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] - - -class _ConfigFlowBase: - """Minimal stand-in for homeassistant ConfigFlow.""" - - VERSION = 1 - - def __init_subclass__(cls, domain=None, **kwargs): - super().__init_subclass__(**kwargs) - cls._domain = domain - - def __init__(self): - self.context = {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id} - - def async_abort(self, *, reason, **kwargs): - return {"type": "abort", "reason": reason} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - async def async_set_unique_id(self, unique_id, raise_on_progress=True): - self.context["unique_id"] = unique_id - return None - - def _async_current_entries(self): - return self.hass.config_entries.async_entries(self._domain) - - def _async_in_progress(self): - return [] - - -sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase import pytest @@ -67,79 +15,53 @@ STRINGS_PATH = PKG_DIR / "strings.json" TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" -def _run(coro): - import asyncio - - return asyncio.new_event_loop().run_until_complete(coro) - - -def _make_flow(existing_entries: list | None = None, in_progress: list | None = None): - """Create a config flow instance with a stubbed hass.""" - flow = config_flow.NestQuestConfigFlow() - flow.hass = SimpleNamespace( - config_entries=SimpleNamespace( - async_entries=lambda domain: [ - entry - for entry in (existing_entries or []) - if entry.domain == domain - ] - ) - ) - if in_progress: - flow._async_in_progress = lambda: in_progress - return flow - - -def _make_entry(domain: str): - """Create a stub config entry with the given domain.""" - return SimpleNamespace(domain=domain) - - -def test_flow_shows_form_initially() -> None: +async def test_flow_shows_form_initially(hass, make_flow) -> None: """The initial user step shows a form.""" - flow = _make_flow() - result = _run(flow.async_step_user(None)) + flow = make_flow(hass) + result = await flow.async_step_user(None) assert result["type"] == "form" assert result["step_id"] == "user" -def test_flow_creates_entry_on_submit() -> None: +async def test_flow_creates_entry_on_submit(hass, make_flow) -> None: """Submitting the user step creates a config entry.""" - flow = _make_flow() - result = _run(flow.async_step_user({})) + flow = make_flow(hass) + result = await flow.async_step_user({}) assert result["type"] == "create_entry" assert result["title"] == "NestQuest" assert result["data"] == {} -def test_second_flow_aborts_single_instance() -> None: +async def test_second_flow_aborts_single_instance( + hass, make_flow, make_config_flow_entry +) -> None: """A second flow aborts with single_instance_allowed.""" - flow = _make_flow([_make_entry(DOMAIN)]) - result = _run(flow.async_step_user(None)) + flow = make_flow(hass, existing_entries=[make_config_flow_entry(DOMAIN)]) + result = await flow.async_step_user(None) assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" -def test_flow_aborts_when_in_progress() -> None: +async def test_flow_aborts_when_in_progress(hass, make_flow) -> None: """A concurrent flow in progress aborts with single_instance_allowed.""" - flow = _make_flow(in_progress=[{"flow_id": "other"}]) - result = _run(flow.async_step_user(None)) + flow = make_flow(hass, in_progress=[{"flow_id": "other"}]) + result = await flow.async_step_user(None) assert result["type"] == "abort" assert result["reason"] == "single_instance_allowed" -def test_flow_sets_stable_unique_id() -> None: +async def test_flow_sets_stable_unique_id(hass, make_flow) -> None: """The flow assigns a stable unique id equal to the domain.""" - flow = _make_flow() + flow = make_flow(hass) assert flow.context.get("unique_id") is None - _run(flow.async_step_user(None)) + await flow.async_step_user(None) assert flow.context["unique_id"] == DOMAIN -def test_flow_ignores_other_domain_entries() -> None: +async def test_flow_ignores_other_domain_entries(hass, make_flow, make_config_flow_entry) -> None: """Entries of other domains do not trigger the abort.""" - flow = _make_flow([_make_entry("other_domain")]) - result = _run(flow.async_step_user(None)) + flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")]) + result = await flow.async_step_user(None) assert result["type"] == "form" assert result["step_id"] == "user" diff --git a/tests/test_const.py b/tests/test_const.py index b4fa982..aea1bd9 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -5,12 +5,6 @@ import ast from datetime import datetime import logging from pathlib import Path -import sys -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"): - sys.modules.setdefault(_mod, MagicMock()) from custom_components.nestquest import const diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 5908cae..a6d8837 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,249 +2,172 @@ from __future__ import annotations import asyncio -import inspect -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] - -import pytest +from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST -def _run(coro): - return asyncio.new_event_loop().run_until_complete(coro) +def _wire(entry, registry): + return wire_entry_to_registry(entry, registry) -class _ListenerRegistry: - """Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers.""" - - def __init__(self, hass=None): - self._hass = hass - self._listeners: dict[str, list[object]] = {} - self.reloaded: list[str] = [] - self._loop = asyncio.new_event_loop() - - def add(self, entry_id: str, listener) -> object: - def _remove(): - self._listeners.get(entry_id, []).remove(listener) - - self._listeners.setdefault(entry_id, []).append(listener) - return _remove - - def dispatch_options_update(self, entry) -> None: - for listener in list(self._listeners.get(entry.entry_id, [])): - result = listener(self._hass, entry) - if inspect.isawaitable(result): - self._loop.run_until_complete(result) - - @property - def size(self) -> int: - return sum(len(listeners) for listeners in self._listeners.values()) - - -def _make_hass(): - hass = SimpleNamespace( - data={}, - config_entries=SimpleNamespace( - async_reload=None, - ), - ) - registry = _ListenerRegistry(hass) - async def _async_reload(entry_id: str) -> None: - registry.reloaded.append(entry_id) - - hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) - return hass, registry - - -def _make_entry(entry_id: str = "test_entry", options: dict | None = None): - entry = SimpleNamespace(entry_id=entry_id, options=dict(options or {})) - entry.add_update_listener = None - return entry - - -def _wire_entry(entry, registry: _ListenerRegistry): - entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) - return entry - - -def test_setup_stores_runtime_data_in_hass_data() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(), _ListenerRegistry()) - assert _run(async_setup_entry(hass, entry)) is True +async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True assert entry.entry_id in hass.data[DOMAIN] assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data -def test_setup_runtime_data_has_entry_id_and_options() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(options={"horizon_days": 7}), _ListenerRegistry()) - _run(async_setup_entry(hass, entry)) +async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None: + entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) + await async_setup_entry(hass, entry) runtime = entry.runtime_data assert runtime.entry_id == entry.entry_id assert runtime.options == {"horizon_days": 7} assert callable(runtime.remove_update_listener) -def test_unload_returns_true_and_removes_hass_data() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - assert _run(async_unload_entry(hass, entry)) is True - assert entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_is_idempotent() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - assert _run(async_unload_entry(hass, entry)) is True - assert _run(async_unload_entry(hass, entry)) is True - assert entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_without_setup_returns_true() -> None: - hass, _ = _make_hass() - entry = _make_entry() - assert _run(async_unload_entry(hass, entry)) is True - assert DOMAIN not in hass.data or entry.entry_id not in hass.data[DOMAIN] - - -def test_unload_clears_entry_runtime_data() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) - assert entry.runtime_data is None - - -def test_unload_removes_update_listener() -> None: - hass, _ = _make_hass() - registry = _ListenerRegistry() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) +async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + assert await async_setup_entry(hass, entry) is True assert registry.size == 1 - _run(async_unload_entry(hass, entry)) + assert await async_unload_entry(hass, entry) is True + assert hass.data == {} + assert DOMAIN not in hass.data + assert entry.runtime_data is None assert registry.size == 0 -def test_options_update_triggers_reload_once() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) +async def test_unload_is_idempotent(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_without_setup_returns_true(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + + +async def test_unload_clears_entry_runtime_data(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + assert entry.runtime_data is None + + +async def test_unload_removes_update_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + assert registry.size == 1 + await async_unload_entry(hass, entry) + assert registry.size == 0 + + +async def test_options_update_triggers_reload_once(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) entry.options = {"horizon_days": 30} - registry.dispatch_options_update(entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] -def test_no_reload_after_unload() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) - registry.dispatch_options_update(entry) +async def test_no_reload_after_unload(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 0 -def test_three_reload_cycles_keep_single_listener() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) +async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - assert _run(async_setup_entry(hass, entry)) is True + assert await async_setup_entry(hass, entry) is True assert registry.size == 1 - assert _run(async_unload_entry(hass, entry)) is True + assert await async_unload_entry(hass, entry) is True assert registry.size == 0 assert hass.config_entries.async_reload.call_count == 0 -def test_three_setup_unload_cycles_leave_no_listeners() -> None: +async def test_three_setup_unload_cycles_leave_no_listeners(hass, make_entry) -> None: """Listeners accumulate per entry (HA semantics); unload must remove them all.""" - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - _run(async_unload_entry(hass, entry)) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) assert registry._listeners.get(entry.entry_id, []) == [] assert registry.size == 0 -def test_options_update_after_each_setup_invokes_exactly_one_listener() -> None: +async def test_options_update_after_each_setup_invokes_exactly_one_listener( + hass, make_entry +) -> None: """After each setup, an options update dispatches to exactly one listener.""" - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - registry.dispatch_options_update(entry) + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] - _run(async_unload_entry(hass, entry)) + await async_unload_entry(hass, entry) hass.config_entries.async_reload.reset_mock() registry.reloaded.clear() -def test_reload_setup_cycles_dispatch_reload_each_time() -> None: - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) +async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None: + registry = hass.registry + entry = _wire(make_entry(), registry) for _ in range(3): - _run(async_setup_entry(hass, entry)) - registry.dispatch_options_update(entry) - _run(async_unload_entry(hass, entry)) + await async_setup_entry(hass, entry) + await registry.async_dispatch_options_update(entry) + await async_unload_entry(hass, entry) assert hass.config_entries.async_reload.call_count == 3 assert registry.size == 0 -def test_no_orphaned_tasks_after_unload() -> None: - async def _scenario(): - hass, registry = _make_hass() - entry = _wire_entry(_make_entry(), registry) - await async_setup_entry(hass, entry) - await async_unload_entry(hass, entry) - pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] - assert pending == [] - - _run(_scenario()) +async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + await async_unload_entry(hass, entry) + pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + assert pending == [] -def test_setup_entry_uses_domain_constant() -> None: - hass, _ = _make_hass() - entry = _wire_entry(_make_entry(), _ListenerRegistry()) - _run(async_setup_entry(hass, entry)) +async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) assert set(hass.data) == {DOMAIN_CONST} -def test_setup_awaitable_listener_removal_supported() -> None: +async def test_setup_awaitable_listener_removal_supported() -> None: """A coroutine-returning remover is awaited during unload.""" + removed = [] - async def _scenario(): - removed = [] + async def _remover(): + removed.append(True) - async def _remover(): - removed.append(True) - - entry = SimpleNamespace(entry_id="awaitable", options={}) - entry.add_update_listener = lambda listener: _remover - hass = SimpleNamespace(data={}, config_entries=SimpleNamespace(async_reload=None)) - await async_setup_entry(hass, entry) - result = await async_unload_entry(hass, entry) - assert result is True - assert removed == [True] - - _run(_scenario()) \ No newline at end of file + entry = make_config_entry(entry_id="awaitable") + entry.add_update_listener = lambda listener: _remover + hass, _registry = make_hass() + await async_setup_entry(hass, entry) + result = await async_unload_entry(hass, entry) + assert result is True + assert removed == [True] + assert hass.data == {} + assert entry.runtime_data is None \ No newline at end of file diff --git a/tests/test_manifest.py b/tests/test_manifest.py index e442a23..ebdde54 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -3,12 +3,6 @@ from __future__ import annotations import json from pathlib import Path -import sys -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment -for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"): - sys.modules.setdefault(_mod, MagicMock()) import pytest diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 71d73c4..d432299 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -3,129 +3,14 @@ from __future__ import annotations import ast import asyncio -import importlib -import inspect import json from pathlib import Path -import sys from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock - -# Mock homeassistant modules if not installed in environment. -# The mock mirrors the REAL Home Assistant 2024.6 contract: -# config_entries.OptionsFlowWithConfigEntry accepts a config_entry kwarg and -# exposes .options / .config_entry; homeassistant.core.callback exists as a -# decorator; data_entry_flow.RESULT_TYPE_* constants and FlowResult exist. -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", - "homeassistant.data_entry_flow", -): - sys.modules.setdefault(_mod, MagicMock()) - -# Wire parent package attributes to the actual module mocks so that -# "from homeassistant import config_entries" resolves to the mocked module. -sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] -sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] - - -class _OptionsFlowWithConfigEntryBase: - """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" - - def __init__(self, config_entry=None): - self.config_entry = config_entry - self.hass = None - self.options = dict(config_entry.options) if config_entry is not None else {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id, **kwargs} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - -class _ConfigFlowBase: - """Stand-in for homeassistant ConfigFlow; a superset of test_config_flow's base. - - test_config_flow.py may be imported after this file, so the concrete - NestQuestConfigFlow can end up bound to this base. It must therefore also - satisfy that file's tests. - """ - - VERSION = 1 - - def __init_subclass__(cls, domain=None, **kwargs): - super().__init_subclass__(**kwargs) - cls._domain = domain - - def __init__(self): - self.context = {} - - def async_show_form(self, *, step_id, **kwargs): - return {"type": "form", "step_id": step_id, **kwargs} - - def async_abort(self, *, reason, **kwargs): - return {"type": "abort", "reason": reason} - - def async_create_entry(self, *, title, data, **kwargs): - return {"type": "create_entry", "title": title, "data": data} - - async def async_set_unique_id(self, unique_id, raise_on_progress=True): - self.context["unique_id"] = unique_id - return None - - def _async_current_entries(self): - return self.hass.config_entries.async_entries(self._domain) - - def _async_in_progress(self): - return [] - - @staticmethod - def async_get_options_flow(config_entry): - raise NotImplementedError - - -def _callback_decorator(fn): - """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" - fn.__ha_callback__ = True - return fn - - -_config_entries_mock = sys.modules["homeassistant.config_entries"] -_core_mock = sys.modules["homeassistant.core"] -_data_entry_flow_mock = sys.modules["homeassistant.data_entry_flow"] - -# The concrete 2024.6-shaped surface the integration is written against: -# plain OptionsFlow has no config_entry constructor (NQ-001); only -# OptionsFlowWithConfigEntry does. -_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase -_config_entries_mock.ConfigFlow = _ConfigFlowBase -_core_mock.callback = _callback_decorator - -# data_entry_flow constants/annotations referenced by the production modules. -_data_entry_flow_mock.RESULT_TYPE_FORM = "form" -_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" -_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" -_data_entry_flow_mock.FlowResult = dict - -# test_config_flow.py may run first and import the integration modules against a -# bare MagicMock surface. Force a clean re-import now that the 2024.6-shaped -# mocks are installed, so the concrete classes bind to them. -for _name in [ - name - for name in sys.modules - if name.startswith("custom_components.nestquest") -]: - del sys.modules[_name] - -importlib.import_module("custom_components.nestquest") -importlib.import_module("custom_components.nestquest.config_flow") -importlib.import_module("custom_components.nestquest.options_flow") import pytest +from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base + from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( @@ -153,53 +38,12 @@ def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) -class _ListenerRegistry: - """Models HA's update-listener bookkeeping with reload tracking.""" - - def __init__(self, hass=None): - self._hass = hass - self._listeners: dict[str, list[object]] = {} - self.reloaded: list[str] = [] - self._loop = asyncio.new_event_loop() - - def add(self, entry_id: str, listener) -> object: - def _remove(): - self._listeners.get(entry_id, []).remove(listener) - - self._listeners.setdefault(entry_id, []).append(listener) - return _remove - - def dispatch_options_update(self, entry) -> None: - for listener in list(self._listeners.get(entry.entry_id, [])): - result = listener(self._hass, entry) - if inspect.isawaitable(result): - self._loop.run_until_complete(result) - - @property - def size(self) -> int: - return sum(len(listeners) for listeners in self._listeners.values()) - - def _make_hass(): - hass = SimpleNamespace( - data={}, - config_entries=SimpleNamespace(async_reload=None), - ) - registry = _ListenerRegistry(hass) - - async def _async_reload(entry_id: str) -> None: - registry.reloaded.append(entry_id) - - hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) - return hass, registry + return make_hass() def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): - entry = SimpleNamespace( - entry_id=entry_id, options=dict(options or {}), data=dict(data or {}) - ) - entry.add_update_listener = None - return entry + return make_config_entry(entry_id=entry_id, options=options, data=data) def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: @@ -237,7 +81,7 @@ def test_options_flow_constructs_through_2024_6_surface() -> None: """The handler constructs against the mocked 2024.6 base and inherits its contract.""" entry = _make_entry(options={CONF_HORIZON_DAYS: 21}) flow = options_flow.NestQuestOptionsFlow(config_entry=entry) - assert isinstance(flow, sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry) + assert isinstance(flow, _options_flow_base()) assert flow.config_entry is entry assert flow.options == {CONF_HORIZON_DAYS: 21} @@ -513,7 +357,7 @@ def test_options_flow_no_plain_dict_schema_fallback() -> None: def test_options_flow_subclasses_options_flow_with_config_entry() -> None: """NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry.""" - base = sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry + base = _options_flow_base() assert issubclass(options_flow.NestQuestOptionsFlow, base) assert options_flow.NestQuestOptionsFlow.__bases__ == (base,) diff --git a/tests/test_store.py b/tests/test_store.py index 992fbbe..95866b4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -4,17 +4,8 @@ from __future__ import annotations import ast import asyncio from pathlib import Path -import sys from unittest.mock import AsyncMock, MagicMock -# Mock homeassistant modules if not installed in environment -for _mod in ( - "homeassistant", - "homeassistant.core", - "homeassistant.config_entries", -): - sys.modules.setdefault(_mod, MagicMock()) - from custom_components.nestquest import store from custom_components.nestquest.const import SQLITE_DB_FILENAME