Auto-register the dashboard on setup (#176)
This commit was merged in pull request #176.
This commit is contained in:
@@ -5,7 +5,8 @@ SQLite file, the migrations, and every business rule. Setup builds the
|
|||||||
entry's ONE shared API client, the coordinator that polls the panel
|
entry's ONE shared API client, the coordinator that polls the panel
|
||||||
snapshot through it, and the SSE subscription that re-fires the API's
|
snapshot through it, and the SSE subscription that re-fires the API's
|
||||||
transition frames on the HA bus, registers the single
|
transition frames on the HA bus, registers the single
|
||||||
``nestquest.complete_quest`` proxy service, and forwards the sensor and
|
``nestquest.complete_quest`` proxy service, registers the zero-config
|
||||||
|
panel dashboard (Feature 20), and forwards the sensor and
|
||||||
binary_sensor platforms.
|
binary_sensor platforms.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -29,6 +30,7 @@ from .const import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||||
|
from .dashboard import async_register_dashboard
|
||||||
from .frontend import async_register_frontend
|
from .frontend import async_register_frontend
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
from .sse import NestQuestEventStream
|
from .sse import NestQuestEventStream
|
||||||
@@ -87,6 +89,13 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up NestQuest from a config entry."""
|
"""Set up NestQuest from a config entry."""
|
||||||
await async_register_frontend(hass)
|
await async_register_frontend(hass)
|
||||||
|
# The zero-config panel dashboard (Feature 20): register the
|
||||||
|
# storage-mode strategy dashboard if no dashboard under the stable
|
||||||
|
# url_path exists yet. Unavailability of the Lovelace dashboards
|
||||||
|
# collection (YAML mode, lovelace not loaded) logs a warning and
|
||||||
|
# NEVER fails setup; an existing dashboard — ours or the user's —
|
||||||
|
# is left untouched, so a reload cannot duplicate it.
|
||||||
|
await async_register_dashboard(hass)
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = (
|
|||||||
|
|
||||||
SQLITE_DB_FILENAME = "nestquest.db"
|
SQLITE_DB_FILENAME = "nestquest.db"
|
||||||
|
|
||||||
|
# Zero-config panel dashboard (Feature 20): ONE storage-mode Lovelace
|
||||||
|
# dashboard registered on setup whose config carries the bundle's
|
||||||
|
# ``custom:nestquest-party`` strategy (frontend/src/strategy.ts) — the
|
||||||
|
# views are generated at render time from the household roster. The
|
||||||
|
# url_path IS the domain: the stable key idempotency is keyed on (a
|
||||||
|
# reload or a second entry never duplicates it, and a dashboard already
|
||||||
|
# registered under it — ours or the user's — is left untouched).
|
||||||
|
DASHBOARD_URL_PATH = DOMAIN
|
||||||
|
DASHBOARD_TITLE = "NestQuest"
|
||||||
|
DASHBOARD_ICON = "mdi:party-popper"
|
||||||
|
DASHBOARD_STRATEGY_TYPE = "custom:nestquest-party"
|
||||||
|
|
||||||
# Configuration keys and default values
|
# Configuration keys and default values
|
||||||
CONF_HORIZON_DAYS = "horizon_days"
|
CONF_HORIZON_DAYS = "horizon_days"
|
||||||
DEFAULT_HORIZON_DAYS = 14
|
DEFAULT_HORIZON_DAYS = 14
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Zero-config panel dashboard registration for NestQuest (Feature 20).
|
||||||
|
|
||||||
|
On setup the integration registers ONE storage-mode Lovelace dashboard
|
||||||
|
whose CONFIG uses the bundle's ``custom:nestquest-party`` strategy (see
|
||||||
|
``frontend/src/strategy.ts``): the views themselves are GENERATED at
|
||||||
|
render time from the household roster, so the stored dashboard config
|
||||||
|
carries nothing but the strategy reference.
|
||||||
|
|
||||||
|
Registration mirrors the REAL two-step sequence the lovelace
|
||||||
|
integration itself uses when it creates the onboarding map dashboard
|
||||||
|
(``homeassistant/components/lovelace/__init__.py``,
|
||||||
|
``_create_map_dashboard``):
|
||||||
|
|
||||||
|
1. create the dashboard METADATA through the ``DashboardsCollection``
|
||||||
|
(``hass.data["lovelace"]["dashboards_collection"]``) — url_path,
|
||||||
|
title, icon, sidebar visibility. The strategy reference is NOT part
|
||||||
|
of the create payload: the collection's create schema allows only
|
||||||
|
the metadata fields, so a ``strategy`` key there fails the whole
|
||||||
|
create (this was the silent no-op the first implementation shipped).
|
||||||
|
2. save the dashboard CONFIG — ``{"strategy": ...}`` — through the
|
||||||
|
per-url_path CONTENT manager that the collection's change listener
|
||||||
|
registers under ``hass.data["lovelace"]["dashboards"][url_path]``
|
||||||
|
(a ``LovelaceStorage``), exactly the second step HA performs.
|
||||||
|
|
||||||
|
Registration is IDEMPOTENT — a dashboard already under the stable
|
||||||
|
``url_path`` (ours or a user's) skips BOTH steps and its stored config
|
||||||
|
is never overwritten — and the Lovelace surfaces being unavailable
|
||||||
|
(YAML mode, lovelace not loaded) is a logged warning, never a setup
|
||||||
|
failure.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import (
|
||||||
|
DASHBOARD_ICON,
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
DASHBOARD_TITLE,
|
||||||
|
DASHBOARD_URL_PATH,
|
||||||
|
LOGGER,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = LOGGER
|
||||||
|
|
||||||
|
# Single-flight guard for the check-then-create below: HA can set up
|
||||||
|
# several config entries CONCURRENTLY, and two overlapping setups would
|
||||||
|
# both pass the exists-check and race on the url_path create. The lock
|
||||||
|
# is per hass instance (kept as an attribute on the hass object, since
|
||||||
|
# hass.data is the integrations' shared registry the unload path
|
||||||
|
# empties) rather than one module-level asyncio.Lock — a bare
|
||||||
|
# module-level lock binds to whichever event loop first awaits it and
|
||||||
|
# raises RuntimeError on any other loop, while the race it must prevent
|
||||||
|
# only exists between setups running on the SAME hass anyway.
|
||||||
|
_REGISTRATION_LOCK_ATTR = "_nestquest_dashboard_registration_lock"
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_lock(hass: HomeAssistant) -> asyncio.Lock:
|
||||||
|
"""Return the per-hass registration lock, creating it on first use."""
|
||||||
|
lock = getattr(hass, _REGISTRATION_LOCK_ATTR, None)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
setattr(hass, _REGISTRATION_LOCK_ATTR, lock)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _lovelace_data(hass: HomeAssistant) -> dict | None:
|
||||||
|
"""Return ``hass.data["lovelace"]`` when it is the real dict shape.
|
||||||
|
|
||||||
|
The lovelace integration stores a plain DICT of surfaces (``mode``,
|
||||||
|
``dashboards``, ``dashboards_collection``, ``resources``,
|
||||||
|
``yaml_dashboards``) under the ``lovelace`` key; anything else
|
||||||
|
(missing key, unexpected object) means the surfaces below cannot be
|
||||||
|
reached and registration must warn and continue.
|
||||||
|
"""
|
||||||
|
lovelace_data = hass.data.get("lovelace")
|
||||||
|
if not isinstance(lovelace_data, dict):
|
||||||
|
return None
|
||||||
|
return lovelace_data
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboards_collection(hass: HomeAssistant):
|
||||||
|
"""Return the Lovelace dashboards METADATA collection, or None.
|
||||||
|
|
||||||
|
The collection lives at ``hass.data["lovelace"]
|
||||||
|
["dashboards_collection"]`` — NOT under ``["dashboards"]``, which
|
||||||
|
maps url_path to the per-dashboard CONTENT managers
|
||||||
|
(``LovelaceStorage``/``LovelaceYAML``). A missing key (lovelace
|
||||||
|
not loaded) and a missing/None entry (YAML mode never sets the
|
||||||
|
collection) both mean "cannot create storage dashboards here" —
|
||||||
|
callers log and continue. The presence of the surface is verified
|
||||||
|
by capability (``async_create_item`` callable), not by type, so no
|
||||||
|
lovelace import is needed.
|
||||||
|
"""
|
||||||
|
lovelace_data = _lovelace_data(hass)
|
||||||
|
if lovelace_data is None:
|
||||||
|
return None
|
||||||
|
collection = lovelace_data.get("dashboards_collection")
|
||||||
|
if collection is None or not callable(
|
||||||
|
getattr(collection, "async_create_item", None)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_store(hass: HomeAssistant, url_path: str):
|
||||||
|
"""Return a url_path's dashboard CONTENT manager, or None.
|
||||||
|
|
||||||
|
After a metadata create, the collection's change listener registers
|
||||||
|
a ``LovelaceStorage`` for the new dashboard under
|
||||||
|
``hass.data["lovelace"]["dashboards"][url_path]``; the dashboard
|
||||||
|
CONFIG (the strategy reference) is saved through THAT manager —
|
||||||
|
the second step of HA's own dashboard creation.
|
||||||
|
"""
|
||||||
|
lovelace_data = _lovelace_data(hass)
|
||||||
|
if lovelace_data is None:
|
||||||
|
return None
|
||||||
|
dashboards = lovelace_data.get("dashboards")
|
||||||
|
if not isinstance(dashboards, dict):
|
||||||
|
return None
|
||||||
|
store = dashboards.get(url_path)
|
||||||
|
if store is None or not callable(getattr(store, "async_save", None)):
|
||||||
|
return None
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _item_url_path(item) -> str | None:
|
||||||
|
"""Read a dashboard item's ``url_path`` through either access shape.
|
||||||
|
|
||||||
|
The collection hands back storage items as dicts while builtin
|
||||||
|
dashboards come back as config objects; the url_path is reachable
|
||||||
|
through ``item["url_path"]`` or ``item.url_path`` respectively.
|
||||||
|
"""
|
||||||
|
url_path = getattr(item, "url_path", None)
|
||||||
|
if isinstance(url_path, str):
|
||||||
|
return url_path
|
||||||
|
get_url_path = getattr(item, "get", None)
|
||||||
|
if callable(get_url_path):
|
||||||
|
value = get_url_path("url_path")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_dashboard(items) -> bool:
|
||||||
|
"""Whether a dashboard under the stable url_path already exists."""
|
||||||
|
return any(_item_url_path(item) == DASHBOARD_URL_PATH for item in items)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_register_dashboard(hass: HomeAssistant) -> bool:
|
||||||
|
"""Register the NestQuest panel dashboard exactly once.
|
||||||
|
|
||||||
|
Returns True when a dashboard under the stable ``url_path`` exists
|
||||||
|
after the call (pre-existing or created here), False when the
|
||||||
|
Lovelace surfaces were unavailable or refused. A dashboard already
|
||||||
|
registered under the stable ``url_path`` — whether ours from a
|
||||||
|
previous setup or a user's own — skips BOTH steps and is left
|
||||||
|
COMPLETELY alone: its stored config is never overwritten, it is
|
||||||
|
never deleted, never a duplicate. The strategy config is saved
|
||||||
|
ONLY for the dashboard created in THIS call, whose fresh content
|
||||||
|
store is empty, so the save cannot clobber anything.
|
||||||
|
"""
|
||||||
|
collection = _dashboards_collection(hass)
|
||||||
|
if collection is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Lovelace dashboards collection unavailable; the NestQuest "
|
||||||
|
"panel dashboard was not registered. Enable the Lovelace "
|
||||||
|
"integration in storage mode or create a dashboard with "
|
||||||
|
"strategy %s manually.",
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
# Single-flight: two concurrent entry setups must not both pass
|
||||||
|
# the exists-check and race on the url_path create.
|
||||||
|
async with _registration_lock(hass):
|
||||||
|
# async_items() is a SYNC @callback on the real collection
|
||||||
|
# (helpers/collection.py ObservableCollection) — not awaited.
|
||||||
|
if _existing_dashboard(collection.async_items()):
|
||||||
|
return True
|
||||||
|
await collection.async_create_item(
|
||||||
|
{
|
||||||
|
"url_path": DASHBOARD_URL_PATH,
|
||||||
|
"title": DASHBOARD_TITLE,
|
||||||
|
"icon": DASHBOARD_ICON,
|
||||||
|
"show_in_sidebar": True,
|
||||||
|
"allow_single_word": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Step two (mirroring HA's _create_map_dashboard): the
|
||||||
|
# strategy CONFIG goes through the fresh dashboard's
|
||||||
|
# content manager, not through the metadata collection.
|
||||||
|
store = _dashboard_store(hass, DASHBOARD_URL_PATH)
|
||||||
|
if store is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The NestQuest panel dashboard was registered but its "
|
||||||
|
"Lovelace content manager never appeared; add the "
|
||||||
|
"strategy %s to the dashboard manually.",
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
await store.async_save(
|
||||||
|
{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Registering the NestQuest panel dashboard in the Lovelace "
|
||||||
|
"dashboards collection failed; set it up manually with "
|
||||||
|
"strategy %s.",
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"frontend",
|
"frontend",
|
||||||
"http"
|
"http",
|
||||||
|
"lovelace"
|
||||||
],
|
],
|
||||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
@@ -12,12 +12,100 @@ from custom_components.nestquest.const import (
|
|||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
)
|
)
|
||||||
|
from custom_components.nestquest.dashboard import (
|
||||||
|
DASHBOARD_STRATEGY_TYPE,
|
||||||
|
DASHBOARD_URL_PATH,
|
||||||
|
async_register_dashboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _wire(entry, registry):
|
def _wire(entry, registry):
|
||||||
return wire_entry_to_registry(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:
|
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||||
entry = _wire(make_entry(), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
@@ -271,3 +359,161 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
|
|||||||
assert entry.runtime_data is None
|
assert entry.runtime_data is None
|
||||||
assert DOMAIN_CONST not in hass.data
|
assert DOMAIN_CONST not in hass.data
|
||||||
assert hass.time_change.size == 0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user