fix: centralize hass fixture usage and clean domain residue on unload

This commit is contained in:
Joshua King
2026-09-13 19:04:20 -04:00
parent 2dbb309844
commit e2d21f2e39
8 changed files with 394 additions and 488 deletions
+4 -1
View File
@@ -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:
+258 -23
View File
@@ -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()
_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
+20 -98
View File
@@ -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"
-6
View File
@@ -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
+106 -183
View File
@@ -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())
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
-6
View File
@@ -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
+6 -162
View File
@@ -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,)
-9
View File
@@ -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