Auto-register the dashboard on setup #176
@@ -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
|
||||
snapshot through it, and the SSE subscription that re-fires the API's
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +30,7 @@ from .const import (
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||
from .dashboard import async_register_dashboard
|
||||
from .frontend import async_register_frontend
|
||||
from .services import async_deregister_services, async_register_services
|
||||
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:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
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, {})
|
||||
|
||||
async def _async_update_listener(
|
||||
|
||||
@@ -43,6 +43,18 @@ DOMAIN_SERVICES: tuple[str, ...] = (
|
||||
|
||||
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
|
||||
CONF_HORIZON_DAYS = "horizon_days"
|
||||
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,
|
||||
"dependencies": [
|
||||
"frontend",
|
||||
"http"
|
||||
"http",
|
||||
"lovelace"
|
||||
],
|
||||
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -12,12 +12,100 @@ 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
|
||||
@@ -271,3 +359,161 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user