A fresh HA install with children configured should show the panel with no YAML and no manual dashboard creation: setup now registers ONE storage-mode Lovelace dashboard whose config is the bundle's custom:nestquest-party strategy (frontend/src/strategy.ts), under the stable url_path 'nestquest' with a title, icon and sidebar visibility. Registration is idempotent — the url_path is the idempotency key, so a reload or a second entry never duplicates the dashboard, and a dashboard already occupying the url_path (ours or the user's) is left completely alone (never overwritten, never deleted). An unavailable Lovelace dashboards collection (YAML mode, lovelace not loaded) or a refused create logs a clear warning and setup continues — never a crash. Tests cover create-on-setup, no-duplicate-on-reload, the untouched user dashboard, the missing collection and a refusing one.
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""Tests for the setup/unload lifecycle of NestQuest."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
def _wire(entry, registry):
|
|
return wire_entry_to_registry(entry, registry)
|
|
|
|
|
|
class FakeDashboardsCollection:
|
|
"""Stand-in for the Lovelace dashboards collection.
|
|
|
|
Records ``async_create_item`` calls the way the real collection
|
|
would persist them, so tests can assert exactly what the setup path
|
|
registered — and that a reload registers nothing more.
|
|
"""
|
|
|
|
def __init__(self, items=None):
|
|
self.items = list(items or [])
|
|
self.created: list[dict] = []
|
|
|
|
async def async_items(self):
|
|
return list(self.items)
|
|
|
|
async def async_create_item(self, item_config: dict) -> None:
|
|
self.created.append(dict(item_config))
|
|
self.items.append(dict(item_config))
|
|
|
|
|
|
def _install_dashboard_collection(hass, items=None) -> FakeDashboardsCollection:
|
|
collection = FakeDashboardsCollection(items)
|
|
hass.data["lovelace"] = SimpleNamespace(dashboards=collection)
|
|
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, with the
|
|
stable url_path, a title/icon, sidebar visibility and no YAML."""
|
|
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
|
|
created = collection.created[0]
|
|
assert created["url_path"] == DASHBOARD_URL_PATH
|
|
assert created["mode"] == "storage"
|
|
assert created["strategy"] == {"type": DASHBOARD_STRATEGY_TYPE}
|
|
assert created["title"]
|
|
assert created["show_in_sidebar"] is True
|
|
assert "icon" in created
|
|
|
|
|
|
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."""
|
|
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
|
|
# 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
|
|
# 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
|
|
|
|
|
|
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."""
|
|
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
|
|
|
|
|
|
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.data["lovelace"] = SimpleNamespace(dashboards=collection)
|
|
entry = _wire(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
assert collection.created == []
|