fix: centralize hass fixture usage and clean domain residue on unload
This commit is contained in:
@@ -50,7 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""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:
|
if runtime_data is None:
|
||||||
runtime_data = getattr(entry, "runtime_data", None)
|
runtime_data = getattr(entry, "runtime_data", None)
|
||||||
if runtime_data is not None:
|
if runtime_data is not None:
|
||||||
|
|||||||
+258
-23
@@ -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:
|
project supports >=3.11. The documented fallback is therefore used:
|
||||||
``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below.
|
``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below.
|
||||||
|
|
||||||
The tests mock the ``homeassistant`` package via ``sys.modules`` injection;
|
conftest.py is the single place where ``homeassistant`` modules are provided:
|
||||||
this conftest wires the mock modules before any test module import and
|
it first tries to import the real packages (importlib in a try/except) and
|
||||||
provides the standard ``hass`` fixture shape (``config``, ``config_entries``,
|
only when they are absent installs MagicMock stand-ins via ``sys.modules`` and
|
||||||
``data``, ``loop``) that HA's own harness exposes.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
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.
|
# Homeassistant module provisioning (mock-only fallback, real import first).
|
||||||
for _mod in (
|
# ---------------------------------------------------------------------------
|
||||||
|
_HA_MODULES = (
|
||||||
"homeassistant",
|
"homeassistant",
|
||||||
"homeassistant.core",
|
"homeassistant.core",
|
||||||
"homeassistant.config_entries",
|
"homeassistant.config_entries",
|
||||||
"homeassistant.data_entry_flow",
|
"homeassistant.data_entry_flow",
|
||||||
):
|
)
|
||||||
sys.modules.setdefault(_mod, MagicMock())
|
|
||||||
|
|
||||||
# Wire parent package attributes so "from homeassistant import config_entries"
|
_HA_AVAILABLE: dict[str, bool] = {}
|
||||||
# resolves to the mocked submodule, mirroring real HA's package layout.
|
for _mod in _HA_MODULES:
|
||||||
sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"]
|
try:
|
||||||
sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"]
|
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
|
@pytest.fixture
|
||||||
@@ -43,16 +230,64 @@ def hass():
|
|||||||
"""Provide a standard-shaped hass fixture.
|
"""Provide a standard-shaped hass fixture.
|
||||||
|
|
||||||
Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config``
|
Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config``
|
||||||
namespace, a ``config_entries`` manager, a ``data`` dict for integration
|
namespace, a ``config_entries`` manager wired to a listener registry, a
|
||||||
state, and the running event ``loop``.
|
``data`` dict for integration state, and the running event ``loop``.
|
||||||
"""
|
"""
|
||||||
hass = SimpleNamespace(
|
_hass, registry = make_hass()
|
||||||
data={},
|
_hass.registry = registry
|
||||||
config=MagicMock(),
|
_hass.loop = asyncio.new_event_loop()
|
||||||
config_entries=MagicMock(),
|
|
||||||
)
|
|
||||||
hass.loop = asyncio.new_event_loop()
|
|
||||||
try:
|
try:
|
||||||
yield hass
|
yield _hass
|
||||||
finally:
|
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
@@ -4,58 +4,6 @@ from __future__ import annotations
|
|||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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
|
import pytest
|
||||||
|
|
||||||
@@ -67,79 +15,53 @@ STRINGS_PATH = PKG_DIR / "strings.json"
|
|||||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
async def test_flow_shows_form_initially(hass, make_flow) -> None:
|
||||||
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:
|
|
||||||
"""The initial user step shows a form."""
|
"""The initial user step shows a form."""
|
||||||
flow = _make_flow()
|
flow = make_flow(hass)
|
||||||
result = _run(flow.async_step_user(None))
|
result = await flow.async_step_user(None)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
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."""
|
"""Submitting the user step creates a config entry."""
|
||||||
flow = _make_flow()
|
flow = make_flow(hass)
|
||||||
result = _run(flow.async_step_user({}))
|
result = await flow.async_step_user({})
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["title"] == "NestQuest"
|
assert result["title"] == "NestQuest"
|
||||||
assert result["data"] == {}
|
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."""
|
"""A second flow aborts with single_instance_allowed."""
|
||||||
flow = _make_flow([_make_entry(DOMAIN)])
|
flow = make_flow(hass, existing_entries=[make_config_flow_entry(DOMAIN)])
|
||||||
result = _run(flow.async_step_user(None))
|
result = await flow.async_step_user(None)
|
||||||
assert result["type"] == "abort"
|
assert result["type"] == "abort"
|
||||||
assert result["reason"] == "single_instance_allowed"
|
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."""
|
"""A concurrent flow in progress aborts with single_instance_allowed."""
|
||||||
flow = _make_flow(in_progress=[{"flow_id": "other"}])
|
flow = make_flow(hass, in_progress=[{"flow_id": "other"}])
|
||||||
result = _run(flow.async_step_user(None))
|
result = await flow.async_step_user(None)
|
||||||
assert result["type"] == "abort"
|
assert result["type"] == "abort"
|
||||||
assert result["reason"] == "single_instance_allowed"
|
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."""
|
"""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
|
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
|
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."""
|
"""Entries of other domains do not trigger the abort."""
|
||||||
flow = _make_flow([_make_entry("other_domain")])
|
flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")])
|
||||||
result = _run(flow.async_step_user(None))
|
result = await flow.async_step_user(None)
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["step_id"] == "user"
|
assert result["step_id"] == "user"
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,6 @@ import ast
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
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
|
from custom_components.nestquest import const
|
||||||
|
|
||||||
|
|||||||
+106
-183
@@ -2,249 +2,172 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
|
||||||
import sys
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
# Mock homeassistant modules if not installed in environment
|
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||||
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 custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _wire(entry, registry):
|
||||||
return asyncio.new_event_loop().run_until_complete(coro)
|
return wire_entry_to_registry(entry, registry)
|
||||||
|
|
||||||
|
|
||||||
class _ListenerRegistry:
|
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||||
"""Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers."""
|
entry = _wire(make_entry(), hass.registry)
|
||||||
|
assert await async_setup_entry(hass, entry) is True
|
||||||
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
|
|
||||||
assert entry.entry_id in hass.data[DOMAIN]
|
assert entry.entry_id in hass.data[DOMAIN]
|
||||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||||
|
|
||||||
|
|
||||||
def test_setup_runtime_data_has_entry_id_and_options() -> None:
|
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None:
|
||||||
hass, _ = _make_hass()
|
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||||
entry = _wire_entry(_make_entry(options={"horizon_days": 7}), _ListenerRegistry())
|
await async_setup_entry(hass, entry)
|
||||||
_run(async_setup_entry(hass, entry))
|
|
||||||
runtime = entry.runtime_data
|
runtime = entry.runtime_data
|
||||||
assert runtime.entry_id == entry.entry_id
|
assert runtime.entry_id == entry.entry_id
|
||||||
assert runtime.options == {"horizon_days": 7}
|
assert runtime.options == {"horizon_days": 7}
|
||||||
assert callable(runtime.remove_update_listener)
|
assert callable(runtime.remove_update_listener)
|
||||||
|
|
||||||
|
|
||||||
def test_unload_returns_true_and_removes_hass_data() -> None:
|
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
|
||||||
hass, _ = _make_hass()
|
registry = hass.registry
|
||||||
registry = _ListenerRegistry()
|
entry = _wire(make_entry(), registry)
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
assert await async_setup_entry(hass, entry) is True
|
||||||
_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))
|
|
||||||
assert registry.size == 1
|
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
|
assert registry.size == 0
|
||||||
|
|
||||||
|
|
||||||
def test_options_update_triggers_reload_once() -> None:
|
async def test_unload_is_idempotent(hass, make_entry) -> None:
|
||||||
hass, registry = _make_hass()
|
entry = _wire(make_entry(), hass.registry)
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
assert await async_setup_entry(hass, entry) is True
|
||||||
_run(async_setup_entry(hass, entry))
|
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}
|
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 hass.config_entries.async_reload.call_count == 1
|
||||||
assert registry.reloaded == [entry.entry_id]
|
assert registry.reloaded == [entry.entry_id]
|
||||||
|
|
||||||
|
|
||||||
def test_no_reload_after_unload() -> None:
|
async def test_no_reload_after_unload(hass, make_entry) -> None:
|
||||||
hass, registry = _make_hass()
|
registry = hass.registry
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
_run(async_setup_entry(hass, entry))
|
await async_setup_entry(hass, entry)
|
||||||
_run(async_unload_entry(hass, entry))
|
await async_unload_entry(hass, entry)
|
||||||
registry.dispatch_options_update(entry)
|
await registry.async_dispatch_options_update(entry)
|
||||||
assert hass.config_entries.async_reload.call_count == 0
|
assert hass.config_entries.async_reload.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_three_reload_cycles_keep_single_listener() -> None:
|
async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None:
|
||||||
hass, registry = _make_hass()
|
registry = hass.registry
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
for _ in range(3):
|
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 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 registry.size == 0
|
||||||
assert hass.config_entries.async_reload.call_count == 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."""
|
"""Listeners accumulate per entry (HA semantics); unload must remove them all."""
|
||||||
hass, registry = _make_hass()
|
registry = hass.registry
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
_run(async_setup_entry(hass, entry))
|
await async_setup_entry(hass, entry)
|
||||||
_run(async_unload_entry(hass, entry))
|
await async_unload_entry(hass, entry)
|
||||||
assert registry._listeners.get(entry.entry_id, []) == []
|
assert registry._listeners.get(entry.entry_id, []) == []
|
||||||
assert registry.size == 0
|
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."""
|
"""After each setup, an options update dispatches to exactly one listener."""
|
||||||
hass, registry = _make_hass()
|
registry = hass.registry
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
_run(async_setup_entry(hass, entry))
|
await async_setup_entry(hass, entry)
|
||||||
registry.dispatch_options_update(entry)
|
await registry.async_dispatch_options_update(entry)
|
||||||
assert hass.config_entries.async_reload.call_count == 1
|
assert hass.config_entries.async_reload.call_count == 1
|
||||||
assert registry.reloaded == [entry.entry_id]
|
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()
|
hass.config_entries.async_reload.reset_mock()
|
||||||
registry.reloaded.clear()
|
registry.reloaded.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_reload_setup_cycles_dispatch_reload_each_time() -> None:
|
async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None:
|
||||||
hass, registry = _make_hass()
|
registry = hass.registry
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
_run(async_setup_entry(hass, entry))
|
await async_setup_entry(hass, entry)
|
||||||
registry.dispatch_options_update(entry)
|
await registry.async_dispatch_options_update(entry)
|
||||||
_run(async_unload_entry(hass, entry))
|
await async_unload_entry(hass, entry)
|
||||||
assert hass.config_entries.async_reload.call_count == 3
|
assert hass.config_entries.async_reload.call_count == 3
|
||||||
assert registry.size == 0
|
assert registry.size == 0
|
||||||
|
|
||||||
|
|
||||||
def test_no_orphaned_tasks_after_unload() -> None:
|
async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None:
|
||||||
async def _scenario():
|
entry = _wire(make_entry(), hass.registry)
|
||||||
hass, registry = _make_hass()
|
await async_setup_entry(hass, entry)
|
||||||
entry = _wire_entry(_make_entry(), registry)
|
await async_unload_entry(hass, entry)
|
||||||
await async_setup_entry(hass, entry)
|
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
||||||
await async_unload_entry(hass, entry)
|
assert pending == []
|
||||||
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
|
||||||
assert pending == []
|
|
||||||
|
|
||||||
_run(_scenario())
|
|
||||||
|
|
||||||
|
|
||||||
def test_setup_entry_uses_domain_constant() -> None:
|
async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
||||||
hass, _ = _make_hass()
|
entry = _wire(make_entry(), hass.registry)
|
||||||
entry = _wire_entry(_make_entry(), _ListenerRegistry())
|
await async_setup_entry(hass, entry)
|
||||||
_run(async_setup_entry(hass, entry))
|
|
||||||
assert set(hass.data) == {DOMAIN_CONST}
|
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."""
|
"""A coroutine-returning remover is awaited during unload."""
|
||||||
|
removed = []
|
||||||
|
|
||||||
async def _scenario():
|
async def _remover():
|
||||||
removed = []
|
removed.append(True)
|
||||||
|
|
||||||
async def _remover():
|
entry = make_config_entry(entry_id="awaitable")
|
||||||
removed.append(True)
|
entry.add_update_listener = lambda listener: _remover
|
||||||
|
hass, _registry = make_hass()
|
||||||
entry = SimpleNamespace(entry_id="awaitable", options={})
|
await async_setup_entry(hass, entry)
|
||||||
entry.add_update_listener = lambda listener: _remover
|
result = await async_unload_entry(hass, entry)
|
||||||
hass = SimpleNamespace(data={}, config_entries=SimpleNamespace(async_reload=None))
|
assert result is True
|
||||||
await async_setup_entry(hass, entry)
|
assert removed == [True]
|
||||||
result = await async_unload_entry(hass, entry)
|
assert hass.data == {}
|
||||||
assert result is True
|
assert entry.runtime_data is None
|
||||||
assert removed == [True]
|
|
||||||
|
|
||||||
_run(_scenario())
|
|
||||||
@@ -3,12 +3,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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
|
import pytest
|
||||||
|
|
||||||
|
|||||||
+6
-162
@@ -3,129 +3,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
|
||||||
import inspect
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
from types import SimpleNamespace
|
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
|
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 config_flow
|
||||||
from custom_components.nestquest import options_flow
|
from custom_components.nestquest import options_flow
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
@@ -153,53 +38,12 @@ def _run(coro):
|
|||||||
return asyncio.new_event_loop().run_until_complete(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():
|
def _make_hass():
|
||||||
hass = SimpleNamespace(
|
return make_hass()
|
||||||
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, data: dict | None = None):
|
def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
|
||||||
entry = SimpleNamespace(
|
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||||
entry_id=entry_id, options=dict(options or {}), data=dict(data or {})
|
|
||||||
)
|
|
||||||
entry.add_update_listener = None
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
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."""
|
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||||
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
||||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
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.config_entry is entry
|
||||||
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
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:
|
def test_options_flow_subclasses_options_flow_with_config_entry() -> None:
|
||||||
"""NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry."""
|
"""NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry."""
|
||||||
base = sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry
|
base = _options_flow_base()
|
||||||
assert issubclass(options_flow.NestQuestOptionsFlow, base)
|
assert issubclass(options_flow.NestQuestOptionsFlow, base)
|
||||||
assert options_flow.NestQuestOptionsFlow.__bases__ == (base,)
|
assert options_flow.NestQuestOptionsFlow.__bases__ == (base,)
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,8 @@ from __future__ import annotations
|
|||||||
import ast
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
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 import store
|
||||||
from custom_components.nestquest.const import SQLITE_DB_FILENAME
|
from custom_components.nestquest.const import SQLITE_DB_FILENAME
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user