Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
520 lines
20 KiB
Python
520 lines
20 KiB
Python
"""Tests for the setup/unload lifecycle of NestQuest."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
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,
|
|
SERVICE_COMPLETE_QUEST,
|
|
)
|
|
from custom_components.nestquest.dashboard import (
|
|
DASHBOARD_STRATEGY_TYPE,
|
|
DASHBOARD_URL_PATH,
|
|
async_register_dashboard,
|
|
)
|
|
|
|
|
|
def _wire(entry, registry):
|
|
return wire_entry_to_registry(entry, registry)
|
|
|
|
|
|
class FakeDashboardStore:
|
|
"""Stand-in for a per-dashboard CONTENT manager (LovelaceStorage).
|
|
|
|
In the real API the dashboard CONFIG (the strategy reference) is
|
|
saved through THIS manager's ``async_save``, NOT through the
|
|
metadata collection — so every strategy-save assertion reads here.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.saved: list[dict] = []
|
|
self.config: dict | None = None
|
|
|
|
async def async_save(self, config: dict) -> None:
|
|
self.saved.append(dict(config))
|
|
self.config = dict(config)
|
|
|
|
|
|
class FakeDashboardsCollection:
|
|
"""Stand-in mirroring the REAL Lovelace dashboards API shapes.
|
|
|
|
Faithful to ``homeassistant/components/lovelace`` +
|
|
``helpers/collection.py``: ``async_items()`` is a SYNC ``@callback``
|
|
(ObservableCollection.async_items returns list(data.values())),
|
|
``async_create_item`` IS async, and — mirroring lovelace's
|
|
``storage_dashboard_changed`` listener — a successful create
|
|
registers a fresh CONTENT manager under
|
|
``hass.data["lovelace"]["dashboards"][url_path]``, which is where
|
|
the dashboard config is then saved. Records ``async_create_item``
|
|
calls so tests can assert exactly what the setup path registered —
|
|
and that a reload registers nothing more.
|
|
"""
|
|
|
|
def __init__(self, hass, items=None) -> None:
|
|
self.hass = hass
|
|
self.items = list(items or [])
|
|
self.created: list[dict] = []
|
|
#: url_path -> content manager, mirroring the real listener's
|
|
# registrations (pre-existing items included).
|
|
self.stores: dict[str, FakeDashboardStore] = {}
|
|
|
|
def async_items(self):
|
|
"""SYNC, like the real ``ObservableCollection.async_items``."""
|
|
return list(self.items)
|
|
|
|
async def async_create_item(self, item_config: dict) -> None:
|
|
# The real collection awaits validation and change notification
|
|
# around the item insertion; yield first so two concurrent
|
|
# callers without a single-flight guard would BOTH get here.
|
|
await asyncio.sleep(0)
|
|
self.created.append(dict(item_config))
|
|
self.items.append(dict(item_config))
|
|
self.stores[item_config["url_path"]] = FakeDashboardStore()
|
|
self.hass.data["lovelace"]["dashboards"][item_config["url_path"]] = (
|
|
self.stores[item_config["url_path"]]
|
|
)
|
|
|
|
|
|
def _install_dashboard_collection(
|
|
hass, items=None
|
|
) -> FakeDashboardsCollection:
|
|
"""Install the REAL-API-shaped lovelace surfaces on the hass stub.
|
|
|
|
Mirrors what lovelace's ``async_setup`` leaves behind in storage
|
|
mode: ``hass.data["lovelace"]`` is a DICT with the metadata
|
|
collection under ``dashboards_collection`` and the per-url_path
|
|
CONTENT managers under ``dashboards``. Any pre-existing item gets
|
|
a content manager, exactly as lovelace's change listener would
|
|
have registered one when the dashboard was created.
|
|
"""
|
|
collection = FakeDashboardsCollection(hass, items)
|
|
hass.data["lovelace"] = {
|
|
"mode": "storage",
|
|
"dashboards": {},
|
|
"dashboards_collection": collection,
|
|
}
|
|
for item in collection.items:
|
|
url_path = item.get("url_path")
|
|
store = FakeDashboardStore()
|
|
collection.stores[url_path] = store
|
|
hass.data["lovelace"]["dashboards"][url_path] = store
|
|
return collection
|
|
|
|
|
|
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
|
|
|
|
|
|
async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None:
|
|
entry = _wire(make_entry(), hass.registry)
|
|
await async_setup_entry(hass, entry)
|
|
runtime = entry.runtime_data
|
|
assert runtime.entry_id == entry.entry_id
|
|
assert runtime.coordinator is not None
|
|
assert callable(runtime.remove_update_listener)
|
|
|
|
|
|
async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None:
|
|
"""The integration owns no database any more: the daily
|
|
day-rollover/materialization listener is gone (the API service
|
|
owns the horizon), so setup registers NOTHING against the
|
|
time-change listener bookkeeping."""
|
|
entry = _wire(make_entry(), hass.registry)
|
|
await async_setup_entry(hass, entry)
|
|
assert hass.time_change.size == 0
|
|
|
|
|
|
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
|
|
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
|
|
|
|
|
|
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 = {"update_interval": 60}
|
|
await registry.async_dispatch_options_update(entry)
|
|
assert hass.config_entries.async_reload.call_count == 1
|
|
assert registry.reloaded == [entry.entry_id]
|
|
|
|
|
|
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
|
|
|
|
|
|
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 await async_setup_entry(hass, entry) is True
|
|
assert registry.size == 1
|
|
assert await async_unload_entry(hass, entry) is True
|
|
assert registry.size == 0
|
|
assert hass.config_entries.async_reload.call_count == 0
|
|
|
|
|
|
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."""
|
|
registry = hass.registry
|
|
entry = _wire(make_entry(), registry)
|
|
for _ in range(3):
|
|
await async_setup_entry(hass, entry)
|
|
await async_unload_entry(hass, entry)
|
|
assert registry._listeners.get(entry.entry_id, []) == []
|
|
assert registry.size == 0
|
|
|
|
|
|
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."""
|
|
registry = hass.registry
|
|
entry = _wire(make_entry(), registry)
|
|
for _ in range(3):
|
|
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]
|
|
await async_unload_entry(hass, entry)
|
|
hass.config_entries.async_reload.reset_mock()
|
|
registry.reloaded.clear()
|
|
|
|
|
|
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):
|
|
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
|
|
|
|
|
|
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 == []
|
|
|
|
|
|
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}
|
|
|
|
|
|
async def test_setup_registers_the_completion_service(hass, make_entry) -> None:
|
|
"""The one domain-global service registers with setup and deregisters
|
|
on the last unload."""
|
|
entry = _wire(make_entry(), hass.registry)
|
|
await async_setup_entry(hass, entry)
|
|
assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
|
await async_unload_entry(hass, entry)
|
|
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
|
|
|
|
|
|
async def test_setup_awaitable_listener_removal_supported() -> None:
|
|
"""A coroutine-returning remover is awaited during unload."""
|
|
removed = []
|
|
|
|
async def _remover():
|
|
removed.append(True)
|
|
|
|
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
|
|
|
|
|
|
async def test_setup_unwinds_when_listener_registration_fails() -> None:
|
|
"""A listener-registration failure must not leak partial setup state."""
|
|
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
|
|
|
class _Boom(Exception):
|
|
pass
|
|
|
|
entry = make_config_entry(entry_id="listener-boom")
|
|
entry.add_update_listener = lambda listener: (_ for _ in ()).throw(_Boom())
|
|
hass, _registry = make_hass()
|
|
with pytest.raises(_Boom):
|
|
await async_setup_entry(hass, entry)
|
|
assert hass.data.get(DOMAIN_CONST, {}) == {}
|
|
assert not hasattr(entry, "runtime_data")
|
|
assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST)
|
|
|
|
|
|
async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None:
|
|
"""A raising update-listener remover must not skip the coordinator
|
|
shutdown: the retained error is re-raised only after the teardown
|
|
ran and the runtime record was cleared."""
|
|
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
|
|
|
class _RemoveBoom(Exception):
|
|
pass
|
|
|
|
def _remover():
|
|
raise _RemoveBoom()
|
|
|
|
entry = make_config_entry(entry_id="remove-boom")
|
|
entry.add_update_listener = lambda listener: _remover
|
|
hass, _registry = make_hass()
|
|
await async_setup_entry(hass, entry)
|
|
coordinator = entry.runtime_data.coordinator
|
|
seen = []
|
|
|
|
async def _shutdown():
|
|
seen.append("shutdown")
|
|
|
|
coordinator.async_shutdown = _shutdown
|
|
|
|
with pytest.raises(_RemoveBoom):
|
|
await async_unload_entry(hass, entry)
|
|
assert seen == ["shutdown"], "coordinator shutdown must have run"
|
|
assert entry.runtime_data is None
|
|
assert DOMAIN_CONST not in hass.data
|
|
assert hass.time_change.size == 0
|
|
|
|
|
|
async def test_setup_creates_the_panel_dashboard(hass, make_entry) -> None:
|
|
"""A fresh install gets the storage-mode strategy dashboard via the
|
|
REAL two-step Lovelace sequence: the METADATA (url_path, title,
|
|
icon, sidebar, single-word allowance — nothing else: the real
|
|
create schema rejects unknown keys like ``strategy``) through the
|
|
collection, then the dashboard CONFIG (the strategy reference)
|
|
through the fresh dashboard's content manager."""
|
|
collection = _install_dashboard_collection(hass)
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
# Step 1: the metadata create through the collection.
|
|
assert len(collection.created) == 1
|
|
created = collection.created[0]
|
|
assert created["url_path"] == DASHBOARD_URL_PATH
|
|
assert created["title"]
|
|
assert created["show_in_sidebar"] is True
|
|
assert created["allow_single_word"] is True
|
|
assert "icon" in created
|
|
# The create payload carries METADATA only: a ``strategy`` key is
|
|
# rejected by the real collection's create schema (the silent
|
|
# no-op the first implementation shipped).
|
|
assert "strategy" not in created
|
|
assert "mode" not in created
|
|
# Step 2: the strategy CONFIG saved through the content manager
|
|
# the create registered, with the custom strategy type.
|
|
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
|
|
assert store is collection.stores[DASHBOARD_URL_PATH]
|
|
assert store.saved == [{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}]
|
|
assert store.config == {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
|
|
|
|
|
|
async def test_setup_reload_does_not_duplicate_the_dashboard(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A reload (or a second entry) must not create a second dashboard:
|
|
the one under the stable url_path is reused as-is — BOTH steps are
|
|
skipped, so the strategy config is not re-saved either."""
|
|
collection = _install_dashboard_collection(hass)
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert len(collection.created) == 1
|
|
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
|
|
assert len(store.saved) == 1
|
|
# A reload: setup again on the same hass (the reload cycle) —
|
|
# the existing dashboard must be left alone.
|
|
await async_unload_entry(hass, entry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert len(collection.created) == 1
|
|
assert len(store.saved) == 1
|
|
# And a second entry on the same install.
|
|
other = _wire(make_entry(entry_id="other"), hass.registry)
|
|
assert await async_setup_entry(hass, other) is True
|
|
assert len(collection.created) == 1
|
|
assert len(store.saved) == 1
|
|
|
|
|
|
async def test_setup_leaves_an_existing_user_dashboard_alone(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A dashboard already occupying the stable url_path — the user's
|
|
own — is never overwritten, deleted or duplicated: no metadata
|
|
create AND no strategy save against its content manager."""
|
|
users_dashboard = {"url_path": DASHBOARD_URL_PATH, "title": "My Board"}
|
|
collection = _install_dashboard_collection(hass, items=[users_dashboard])
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert collection.created == []
|
|
assert collection.items[0] is users_dashboard
|
|
users_store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
|
|
assert users_store.saved == []
|
|
assert users_store.config is None
|
|
|
|
|
|
async def test_setup_succeeds_without_the_dashboards_collection(
|
|
hass, make_entry, caplog
|
|
) -> None:
|
|
"""YAML mode / lovelace not loaded: log a warning, continue setup."""
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert entry.entry_id in hass.data[DOMAIN]
|
|
assert any(
|
|
"dashboard" in record.message.lower()
|
|
and record.levelname == "WARNING"
|
|
for record in caplog.records
|
|
)
|
|
|
|
|
|
async def test_setup_survives_a_failing_dashboards_collection(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A collection that refuses the create (permissions, admin-only)
|
|
logs a warning and setup still succeeds — entities come up."""
|
|
class _RefusingCollection(FakeDashboardsCollection):
|
|
async def async_create_item(self, item_config):
|
|
raise PermissionError("not an admin")
|
|
|
|
collection = _RefusingCollection(hass)
|
|
hass.data["lovelace"] = {
|
|
"mode": "storage",
|
|
"dashboards": {},
|
|
"dashboards_collection": collection,
|
|
}
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert collection.created == []
|
|
|
|
|
|
async def test_setup_survives_a_failing_content_manager_save(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A content manager that refuses the config save after the
|
|
metadata create succeeded (storage write failure) logs a warning
|
|
and setup still succeeds — the dashboard exists, the strategy
|
|
simply was not persisted through it."""
|
|
class _RefusingStore(FakeDashboardStore):
|
|
async def async_save(self, config):
|
|
raise OSError("disk full")
|
|
|
|
class _StorelessCreateCollection(FakeDashboardsCollection):
|
|
async def async_create_item(self, item_config):
|
|
await asyncio.sleep(0)
|
|
self.created.append(dict(item_config))
|
|
self.items.append(dict(item_config))
|
|
self.stores[item_config["url_path"]] = _RefusingStore()
|
|
self.hass.data["lovelace"]["dashboards"][
|
|
item_config["url_path"]
|
|
] = self.stores[item_config["url_path"]]
|
|
|
|
collection = _StorelessCreateCollection(hass)
|
|
hass.data["lovelace"] = {
|
|
"mode": "storage",
|
|
"dashboards": {},
|
|
"dashboards_collection": collection,
|
|
}
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert len(collection.created) == 1
|
|
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
|
|
assert store.saved == []
|
|
|
|
|
|
async def test_concurrent_registrations_create_the_dashboard_once(hass) -> None:
|
|
"""Two CONCURRENT registrations (the race two entry setups run)
|
|
must produce exactly ONE dashboard: the single-flight guard makes
|
|
the second caller's exists-check run after the first caller's
|
|
create completed, so it skips both steps. The fake's create
|
|
yields before recording, so without the guard both callers would
|
|
pass the exists-check and the dashboard would be created twice."""
|
|
collection = _install_dashboard_collection(hass)
|
|
results = await asyncio.gather(
|
|
async_register_dashboard(hass),
|
|
async_register_dashboard(hass),
|
|
)
|
|
assert results == [True, True]
|
|
assert len(collection.created) == 1
|
|
assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1
|