P1: The registration was a silent no-op in production — it read hass.data["lovelace"].dashboards as the DashboardsCollection and awaited a sync async_items(), and passed "strategy"/"mode" keys the real create schema rejects. Per the real lovelace source (components/lovelace/__init__.py, _create_map_dashboard): the metadata collection lives at hass.data["lovelace"]["dashboards_collection"], async_items() is a SYNC @callback, the create payload carries metadata only (url_path/title/icon/show_in_sidebar/allow_single_word), and the dashboard CONFIG (the strategy reference) is saved as a SECOND step through the content manager registered under hass.data["lovelace"]["dashboards"][url_path]. The strategy is saved only for a dashboard created in this run (or whose config already carries ours); an existing dashboard skips both steps, so a user's dashboard config is never overwritten. Collection unavailable / save raising still warns and continues, never crashing setup. P2: Declare "lovelace" in manifest.json dependencies (alongside frontend/http) so hass.data["lovelace"] is populated before setup instead of relying on boot order. P3: Guard the check-then-create with a per-hass asyncio.Lock so two concurrent entry setups cannot both pass the exists-check and race on the url_path create. Per-hass (attribute on the hass object) rather than a bare module-level lock, which binds to the first event loop that awaits it. The lifecycle tests' fake is rebuilt to mirror the real API shape (sync async_items, async async_create_item, a dashboards content- manager dict whose entries expose async async_save), with assertions that the strategy config was saved with the custom:nestquest-party type, that a user's existing config is not overwritten, that a failing save still leaves setup green, and that concurrent registrations create the dashboard exactly once (verified to fail without the guard).
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
|