Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8942e998cd | ||
|
|
616ad80214 | ||
|
|
ba994c2648 | ||
|
|
0f4a989db3 | ||
|
|
e2d21f2e39 | ||
|
|
2dbb309844 | ||
|
|
fba5d0726d |
@@ -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:
|
||||
|
||||
@@ -9,9 +9,11 @@ dependencies = []
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Shared pytest fixtures for the NestQuest test suite.
|
||||
|
||||
pytest-homeassistant-custom-component could not be adopted: it pins
|
||||
``pytest==9.0.3`` and ``voluptuous==0.13.1``, which conflicts with the dev
|
||||
group's ``voluptuous>=0.14`` (used directly by the options flow's schema and
|
||||
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.
|
||||
|
||||
conftest.py is the single place where ``homeassistant`` modules are provided:
|
||||
this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent
|
||||
set of 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).
|
||||
|
||||
Run this suite in its own venv *without* homeassistant installed (``uv run
|
||||
pytest -q``); it is never meant to run against a real HA install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Homeassistant module provisioning (explicitly mock-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_MODULES = (
|
||||
"homeassistant",
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
"""find_spec that returns None when the module (or its parent) is absent."""
|
||||
try:
|
||||
return importlib.util.find_spec(name)
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_mock_only():
|
||||
"""Fail fast if real homeassistant is importable: this harness is mock-only."""
|
||||
for _mod in _HA_MODULES:
|
||||
# Anything already in sys.modules at load time is real HA; our mocks
|
||||
# are only installed after this check.
|
||||
if _mod in sys.modules or _find_spec(_mod) is not None:
|
||||
raise pytest.UsageError(
|
||||
"The NestQuest test harness is mock-only: real homeassistant is "
|
||||
f"importable ({_mod!r} found), and mixing real HA modules with the "
|
||||
"shaped mocks is not supported. Run the suite in its own venv "
|
||||
"without homeassistant installed, e.g. `uv run pytest -q`."
|
||||
)
|
||||
|
||||
|
||||
_ensure_mock_only()
|
||||
|
||||
for _mod in _HA_MODULES:
|
||||
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"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
|
||||
def _ha_mock(name: str):
|
||||
"""Return the shaped mock module object for a homeassistant module."""
|
||||
return sys.modules[name]
|
||||
|
||||
|
||||
def options_flow_base():
|
||||
"""Return the mocked OptionsFlowWithConfigEntry class."""
|
||||
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 the flow result only; the flow manager persists the entry."""
|
||||
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
|
||||
|
||||
|
||||
# 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] = []
|
||||
|
||||
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
|
||||
|
||||
async def dispatch_options_update(self, entry) -> None:
|
||||
"""Dispatch on the running loop; sync listeners run inline, async ones awaited."""
|
||||
for listener in list(self._listeners.get(entry.entry_id, [])):
|
||||
result = listener(self._hass, entry)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async_dispatch_options_update = dispatch_options_update
|
||||
|
||||
@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)
|
||||
hass.config_entries._entries = []
|
||||
hass.config_entries.async_add = (
|
||||
lambda entry: hass.config_entries._entries.append(entry)
|
||||
)
|
||||
|
||||
def _async_entries(domain: str):
|
||||
return [e for e in hass.config_entries._entries if e.domain == domain]
|
||||
|
||||
hass.config_entries.async_entries = _async_entries
|
||||
return hass, registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hass():
|
||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||
|
||||
Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config``
|
||||
namespace, a ``config_entries`` manager wired to a listener registry, a
|
||||
``data`` dict for integration state, and a ``loop`` bound to the
|
||||
pytest-asyncio loop the test itself runs on (HA's running-loop contract),
|
||||
so code scheduling on ``hass.loop`` runs on the actual test loop.
|
||||
"""
|
||||
_hass, registry = make_hass()
|
||||
_hass.registry = registry
|
||||
_hass.loop = asyncio.get_running_loop()
|
||||
yield _hass
|
||||
|
||||
|
||||
@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()
|
||||
flow.hass = hass if hass is not None else make_hass()[0]
|
||||
for entry in existing_entries or []:
|
||||
if getattr(entry, "domain", None) == flow._domain:
|
||||
flow.hass.config_entries.async_add(entry)
|
||||
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
|
||||
+31
-99
@@ -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,63 @@ 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:
|
||||
"""Submitting the user step creates a config entry."""
|
||||
flow = _make_flow()
|
||||
result = _run(flow.async_step_user({}))
|
||||
async def test_flow_creates_entry_on_submit(hass, make_flow) -> None:
|
||||
"""Submitting the user step returns a create-entry result with the payload.
|
||||
|
||||
Real HA's ConfigFlow.async_create_entry only returns the flow result; the
|
||||
flow manager persists the entry afterward. The manager surface therefore
|
||||
stays empty here — persistence is the manager's job, not the flow's.
|
||||
"""
|
||||
flow = make_flow(hass)
|
||||
assert hass.config_entries.async_entries(DOMAIN) == []
|
||||
result = await flow.async_step_user({})
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert result["data"] == {}
|
||||
assert hass.config_entries.async_entries(DOMAIN) == []
|
||||
|
||||
|
||||
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))
|
||||
existing = make_config_flow_entry(DOMAIN)
|
||||
flow = make_flow(hass, existing_entries=[existing])
|
||||
assert hass.config_entries.async_entries(DOMAIN) == [existing]
|
||||
result = await flow.async_step_user(None)
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
assert hass.config_entries.async_entries(DOMAIN) == [existing]
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Contract test for the mock-only homeassistant harness in conftest.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
|
||||
from conftest import _HA_MODULES, _ensure_mock_only
|
||||
|
||||
|
||||
def test_mock_only_harness_contract(monkeypatch):
|
||||
"""The harness is mock-only: shaped mocks everywhere, fail-fast on real HA."""
|
||||
# Every required homeassistant module in sys.modules is a mock stand-in
|
||||
# with the shaped surface, and parent-package attributes point at them.
|
||||
for name in _HA_MODULES:
|
||||
assert isinstance(sys.modules[name], MagicMock), f"{name} is not a mock"
|
||||
|
||||
config_entries = sys.modules["homeassistant.config_entries"]
|
||||
assert config_entries.ConfigFlow is conftest._ConfigFlowBase
|
||||
assert config_entries.OptionsFlowWithConfigEntry is (
|
||||
conftest._OptionsFlowWithConfigEntryBase
|
||||
)
|
||||
assert sys.modules["homeassistant"].config_entries is config_entries
|
||||
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
|
||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
||||
|
||||
# No homeassistant distribution is installed in this suite's own venv.
|
||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||
importlib.metadata.version("homeassistant")
|
||||
|
||||
# Fail-fast paths: a findable real HA module, or one preloaded in
|
||||
# sys.modules, both abort collection with a UsageError.
|
||||
real_spec = MagicMock(origin="/fake/site-packages/homeassistant/__init__.py")
|
||||
monkeypatch.setattr(conftest, "_find_spec", lambda name: real_spec)
|
||||
with pytest.raises(pytest.UsageError, match="mock-only"):
|
||||
_ensure_mock_only()
|
||||
|
||||
monkeypatch.setattr(conftest, "_find_spec", lambda name: None)
|
||||
monkeypatch.setitem(sys.modules, "homeassistant", MagicMock(name="real-module"))
|
||||
with pytest.raises(pytest.UsageError, match="mock-only"):
|
||||
_ensure_mock_only()
|
||||
|
||||
|
||||
async def test_hass_and_registry_run_on_the_running_test_loop(hass, make_entry):
|
||||
"""hass.loop is the test's own loop and the registry spins no private loop."""
|
||||
# hass.loop is the very loop the async test runs on (HA running-loop
|
||||
# contract): code scheduling on hass.loop lands on the test loop itself.
|
||||
assert hass.loop is asyncio.get_running_loop()
|
||||
ran = []
|
||||
|
||||
async def _scheduled():
|
||||
ran.append(True)
|
||||
|
||||
await hass.loop.create_task(_scheduled())
|
||||
assert ran == [True]
|
||||
|
||||
# ListenerRegistry never spins its own event loop (no resource leak) and
|
||||
# dispatch is a plain coroutine: sync listeners run inline, async awaited.
|
||||
registry = hass.registry
|
||||
assert not hasattr(registry, "_loop")
|
||||
assert inspect.iscoroutinefunction(registry.dispatch_options_update)
|
||||
|
||||
calls = []
|
||||
|
||||
def _sync_listener(h, entry):
|
||||
calls.append(("sync", entry.entry_id, h is hass))
|
||||
|
||||
entry = make_entry(entry_id="disp")
|
||||
registry.add("disp", _sync_listener)
|
||||
await registry.dispatch_options_update(entry)
|
||||
assert calls == [("sync", "disp", True)]
|
||||
|
||||
async def _async_listener(h, entry):
|
||||
calls.append(("async", entry.entry_id))
|
||||
|
||||
registry.add("disp", _async_listener)
|
||||
calls.clear()
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
assert calls == [("sync", "disp", True), ("async", "disp")]
|
||||
+132
-183
@@ -2,249 +2,198 @@
|
||||
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_of_one_entry_keeps_domain_while_other_remains(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Unloading one of two entries keeps DOMAIN alive with the other entry."""
|
||||
registry = hass.registry
|
||||
entry_a = _wire(make_entry(entry_id="entry_a"), registry)
|
||||
entry_b = _wire(make_entry(entry_id="entry_b"), registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert set(hass.data[DOMAIN]) == {"entry_a", "entry_b"}
|
||||
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert DOMAIN in hass.data
|
||||
assert set(hass.data[DOMAIN]) == {"entry_b"}
|
||||
assert hass.data[DOMAIN]["entry_b"] is entry_b.runtime_data
|
||||
assert entry_a.runtime_data is None
|
||||
assert entry_b.runtime_data is not None
|
||||
assert registry.size == 1
|
||||
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.data == {}
|
||||
assert entry_b.runtime_data is None
|
||||
assert registry.size == 0
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
+7
-163
@@ -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}
|
||||
|
||||
@@ -427,7 +271,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
||||
assert result["type"] == "create_entry"
|
||||
entry.options = dict(result["data"])
|
||||
|
||||
registry.dispatch_options_update(entry)
|
||||
_run(registry.dispatch_options_update(entry))
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
assert entry.options == VALID_INPUT
|
||||
@@ -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,)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nestquest"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "voluptuous"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user