Compare commits

..
Author SHA1 Message Date
overseer 77bc90b3ab Coordinator serves the last-good snapshot within a staleness threshold
WHY: any single typed fetch failure was a failed pass, so the whole
board flipped unavailable on one dropped poll and stayed dark until the
API answered again — even though HA's coordinator machinery keeps the
last good data on a failed pass and the panel could safely keep
rendering it for a while.

The coordinator now caches every successful snapshot with its
time.monotonic stamp; while the elapsed time since the last success is
within the configured staleness threshold (CONF_SNAPSHOT_STALENESS,
seconds, default 3x the update interval = 900 s) a typed failure serves
that cache instead: the panel keeps rendering last-good data, entities
stay available, and the outage is logged as a clear warning.  Past the
threshold — or before the first success — the pass fails as before
(typed error logged and chained into UpdateFailed); a later successful
fetch refreshes the cache and the entities recover with no config-entry
reload.  The threshold rides the options flow (validated as a positive
int at least the update interval, with a dedicated error string) and
the strings files; the clock is read through a _monotonic seam so the
staleness tests drive it deterministically.
2026-09-23 08:04:20 -04:00
dev_agent b0bbb5f8c5 complete_quest proxies to the API (task 47c7c90e) (#169) 2026-09-23 07:52:32 -04:00
dev_agent 84d179336b Task b1ed9c45: SSE subscription re-fires the four HA bus events (#168) 2026-09-23 07:04:39 -04:00
dev_agent fafabeabbe Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167) 2026-09-23 06:33:44 -04:00
dev_agent 78fd75ec45 Task 37d5e806: API client module for the integration (Feature 18) (#166) 2026-09-23 05:12:27 -04:00
87 changed files with 7764 additions and 3683 deletions
+2 -17
View File
@@ -174,23 +174,8 @@ is simply a Home Assistant client.
Assistant user it may also reach other permitted Home Assistant entities
and services.
2. **Build a dashboard with only the NestQuest panel cards.** The
recommended path is the **zero-config dashboard strategy**: create a
Lovelace dashboard whose URL slug is `nestquest` with
```yaml
strategy:
type: custom:nestquest-party
```
and no views of your own — the strategy generates the party-board and
quest-log views (each already in Panel layout) from the household
roster at render time. Optional `url_path` and `weather_entity` keys
may sit beside `strategy:`. The manual instructions below describe
building the two views by hand if you prefer not to use the strategy.
**Manual alternative:** create a Lovelace dashboard whose **URL slug
is `nestquest`** (Settings →
2. **Build a dashboard with only the NestQuest panel cards.** Create a
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
Dashboards → Edit → URL slug). A functional panel needs **two** views
in that dashboard, each with its own card config from
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
+500 -76
View File
@@ -1,26 +1,23 @@
"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service).
The integration owns NO database: the API service (Feature 16) owns the
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, registers the zero-config
panel dashboard (Feature 20), and forwards the sensor and
binary_sensor platforms.
"""
"""The NestQuest integration."""
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_SNAPSHOT_STALENESS,
@@ -29,50 +26,354 @@ from .const import (
LOGGER,
PLATFORMS,
)
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .dashboard import async_register_dashboard
from .core.settings import NestQuestSettings
from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
from .db import NestQuestDatabase, make_database
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .services import async_deregister_services, async_register_services
from .sse import NestQuestEventStream
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
from .sweep import run_missed_sweep
_LOGGER = LOGGER
#: SQLite reports "file is not a database" for corruption in every
#: sqlite3 version this integration targets; the version-specific
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
_CORRUPTION_MARKERS = (
"file is not a database",
"database disk image is malformed",
"encrypted or malformed",
"is not a database",
)
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
#: codes carry the primary code in their low byte, so the match is on
#: errcode & 0xff.
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
def _is_corruption_error(error: BaseException) -> bool:
"""Return True when ``error`` indicates a corrupt database file.
Classification order: sqlite_errorcode (Python 3.11+) is primary
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
classify correctly too. Message matching is the fallback for
older Pythons: the corruption spellings are the documented English
texts; a non-English locale could evade them, but the code check
already covers every locale on supported Pythons.
"""
if not isinstance(error, sqlite3.DatabaseError):
return False
errorcode = getattr(error, "sqlite_errorcode", None)
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
return True
message = str(error).lower()
return any(marker in message for marker in _CORRUPTION_MARKERS)
async def _preflight_existing_file(
hass: HomeAssistant, db_path: Path
) -> None:
"""Detect corruption in an EXISTING file before it is opened read-write.
Opening read-write persists WAL mode — for a rollback-journal file
that rewrites the header byte 18/19 and can create -wal/-shm
sidecars — which would modify a corrupt file before detection and
break the leave-untouched guarantee. This preflight therefore
validates the WHOLE file with a READ-ONLY connection (mode=ro +
immutable=1 URI, PRAGMA integrity_check, run on the executor like
every other DB call): corruption anywhere fails there, before
anything is written. ``immutable=1`` is SQLite's documented
guarantee that the connection performs NO writes and creates NO
sidecars — even a WAL-header file gets its -wal/-shm reads served
without creating the shared-memory file. Missing/empty files skip
the preflight (they are created fresh by the real open). All
filesystem stat/URI work runs inside the executor job with the
probe so the event loop never touches disk.
"""
def _probe() -> None:
# The file-state check and URI construction live inside the
# executor job: exists/stat/resolve are disk I/O.
if not db_path.exists():
return
# A zero-byte file is neither missing nor a valid database: it
# is a corrupt (truncated-to-nothing) existing file, likely an
# interrupted first write. The leave-untouched contract applies
# to it too: refuse rather than silently initialize over it.
if db_path.stat().st_size == 0:
raise sqlite3.DatabaseError(
"existing database file is empty (zero bytes): file is "
"not a database"
)
# Properly escape the path: '#'/'?'/'%' in a directory or file
# name would otherwise be parsed as URI fragments/parameters/
# escapes. immutable=1: SQLite guarantees no writes and no
# sidecar creation from this connection.
uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute("PRAGMA integrity_check").fetchone()
finally:
conn.close()
verdict = row[0] if row else "missing"
if verdict != "ok":
# A non-ok integrity verdict IS corruption: raise with the
# verdict text (which matches the corruption markers) and
# the NOTADB code so classification is code-based.
raise sqlite3.DatabaseError(
f"integrity_check verdict {verdict!r} on read-only "
f"probe: file is not a database"
)
try:
# Every DB-touching call goes through the executor (the mock
# harness's async_add_executor_job is awaitable too).
await hass.async_add_executor_job(_probe)
except sqlite3.DatabaseError as err:
# Non-ok integrity verdicts are corruption, full stop: there is
# no benign reading of a failed whole-file integrity check, so
# ANY DatabaseError out of the probe that carries a corruption
# marker or code propagates as corruption. Only exotic probe
# failures that classify as non-corruption (e.g. a transient
# 'database is locked' on the read) are downgraded to a debug
# log, letting the real open path surface them.
if _is_corruption_error(err):
raise
raise sqlite3.DatabaseError(
f"read-only corruption probe of {db_path} failed: {err}"
) from err
async def _async_open_database(
hass: HomeAssistant, db_path: Path
) -> NestQuestDatabase:
"""Open the NestQuest database, mapping failure modes for setup.
Three startup cases (Feature 02 done-condition):
- file missing: SQLite creates it fresh on open — no special path,
migrations stamp version 1 on the next step.
- file present and valid: opens normally.
- file present but corrupt: log an ERROR with the path, close
anything opened, and raise ConfigEntryNotReady so HA retries —
the file is left byte-for-byte untouched so the owner can
restore a backup. No code path here deletes or overwrites an
existing database file.
Corruption can surface at EITHER stage: a truncated header fails
in the read-only preflight (before the file is ever opened
read-write), while damaged later pages fail during migrations —
the preflight guarantees no WAL sidecar or header write happens
on a corrupt file before it is detected. Both are classified the
same way.
"""
try:
await _preflight_existing_file(hass, db_path)
database = await make_database(hass).open(db_path)
except asyncio.CancelledError:
raise
except sqlite3.DatabaseError as err:
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# open() failed for a non-corruption reason (locked file,
# permission problem): retryable, but nothing was opened.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
try:
await apply_migrations(database)
except (sqlite3.DatabaseError, RuntimeError) as err:
await database.close()
if _is_corruption_error(err):
_LOGGER.error(
"NestQuest database at %s is corrupt: %s. Refusing to "
"start with a corrupt database; the file was left "
"untouched so a backup can be restored. Home Assistant "
"will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database corrupt: {db_path}"
) from err
# A non-corruption failure (e.g. a locked file, a future schema
# version) is also a retryable setup problem, but without the
# corruption framing.
_LOGGER.error(
"NestQuest database at %s failed to open: %s. Home "
"Assistant will retry setup.",
db_path,
err,
)
raise ConfigEntryNotReady(
f"NestQuest database unavailable: {db_path}"
) from err
except asyncio.CancelledError:
await database.close()
raise
return database
@dataclass
class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry."""
entry_id: str
settings: NestQuestSettings
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a coordinator, or None.
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The domain-global service (the panel completion proxy) has no
database of its own; it resolves whichever config entry's runtime
data is currently live at call time, so it cannot hold a stale
handle to an unloaded entry's coordinator.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
silent default; every caller already passes the entry's validated
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
window itself comes from :meth:`settings.horizon_window` so the helper is
real and shared (not recomputed inline here).
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings,
) -> Callable[[], Any]:
"""Register the daily rollover listener and return its remover.
Fires once per day at the configured local ``day_rollover_time``:
first re-materializes the rolling horizon
``[today, today + horizon_days]`` through the shared
:func:`_run_horizon_materialization` path (the entry's configured,
validated ``horizon_days`` carried on ``settings``), then runs the
Feature 11 missed sweep — announcing yesterday's still-open quests
as missed AFTER today's instances exist, watermark-guarded so a
re-fire of the listener the same night announces nothing twice.
"""
hour, minute = settings.day_rollover_hour_minute
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database, settings)
await run_missed_sweep(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
"""Return a live runtime record with a connected database, or None.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also
carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
if getattr(runtime_data, "coordinator", None) is not None:
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
return runtime_data
return None
def _register_services(hass: HomeAssistant) -> None:
"""Register the domain-global NestQuest service once.
def _make_regenerate_handler(hass: HomeAssistant):
"""Return the Feature 07 ``regenerate`` handler closed over ``hass``."""
The service is DOMAIN-global — registered exactly once (guarded by
``has_service``) and NOT bound to any config entry — so the panel
completion proxy resolves the currently-live runtime at call time
async def _regenerate(_call: Any) -> None:
runtime_data = _find_live_runtime_data(hass)
if runtime_data is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(
hass, runtime_data.database, runtime_data.settings
)
return _regenerate
def _register_services(hass: HomeAssistant) -> None:
"""Register domain-global NestQuest services once.
Services are DOMAIN-global — registered exactly once (guarded by
``has_service`` on regenerate) and NOT bound to any config entry —
so each handler resolves the currently-live database at call time
and a name collision or duplicate setup is impossible. Removal is
handled by the unload path only once the LAST entry is removed
(see :func:`_deregister_services`).
"""
async_register_services(hass, find_runtime=_find_live_runtime_data)
async_register_services(
hass,
find_runtime=_find_live_runtime_data,
regenerate_handler=_make_regenerate_handler(hass),
)
def _deregister_services(hass: HomeAssistant) -> None:
@@ -89,14 +390,48 @@ 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, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None or not db.connected:
# Reopen through the SAME safety path as first setup: a
# record without a live connection (a previous setup's
# close-on-failure, or a hand-replaced file) must get the
# missing/valid/corrupt classification too, never a raw
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -104,23 +439,80 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
coordinator: Any = None
event_stream: NestQuestEventStream | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot reset the user's
# horizon_days or day_rollover_time — the two fields core
# consumes — to their defaults.
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + horizon_days] once at setup, using the entry's
# configured horizon. This runs through the SAME path as the daily
# listener and regenerate service.
await _run_horizon_materialization(hass, database, settings)
# The missed sweep runs at startup too (Feature 11): if HA was
# down at the rollover time, the previous night's still-open
# quests are announced as missed now instead of waiting for
# the next midnight. Watermark-guarded, so this is a no-op
# when the night's sweep already ran.
await run_missed_sweep(hass, database)
_register_services(hass)
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Since Feature 18 it polls the API service's
# panel snapshot instead of reading the local database; an
# entry with no panel token yet gets the typed-error stub
# (unavailable entities, never a setup crash). Created AFTER
# services so a failure below unwinds them through the except
# path's listener handling.
# ONE API client for the entry, shared by the coordinator's
# snapshot polling, the SSE subscription, and the completion
# proxy (the client is stateless — one session, one connection
# pool). An entry with no panel token yet gets the
# typed-error stub (unavailable entities, never a setup crash).
# snapshot polling and the SSE subscription below (the client
# is stateless — one session, one connection pool).
api_client = coordinator_client_from_entry(hass, entry)
# The shared coordinator: one refresh cycle every entity reads
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
# It polls the API service's panel snapshot; a failed poll
# keeps the entities rendering the last-good snapshot within
# CONF_SNAPSHOT_STALENESS, unavailable past it.
# The shared Feature 10 coordinator: one refresh cycle every
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
# five minutes). Since Feature 18 it polls the API service's
# panel snapshot instead of reading the local database; an
# entry with no panel token yet gets the typed-error stub
# (unavailable entities, never a setup crash). Created AFTER
# services so a failure below unwinds them through the except
# path's listener handling.
coordinator = NestQuestCoordinator(
hass,
entry_id=entry.entry_id,
@@ -145,16 +537,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
event_stream.start()
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
settings=settings,
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
coordinator=coordinator,
event_stream=event_stream,
)
# The runtime record is registered BEFORE the first refresh:
# the service proxy resolves the entry's runtime state through
# hass.data, and it must find the live coordinator there —
# never a half-set-up entry. The platforms forward below reads
# the coordinator off the entry (the HA runtime-data contract);
# a forward failure below unwinds it through the except path.
# the refresh path (and anything it calls) resolves the entry's
# runtime state through hass.data, and it must find the live
# database there — never a half-set-up entry. The platforms
# forward below reads the coordinator off the entry
# (the HA runtime-data contract); a forward failure below
# unwinds it through the except path.
hass.data[DOMAIN][entry.entry_id] = runtime_data
entry.runtime_data = runtime_data
# The first refresh polls the API snapshot; an API failure
@@ -174,24 +570,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
except BaseException:
# A failure anywhere after the listener was registered must not
# leak it (or the SSE task): unwind EVERYTHING acquired before
# the failure, keeping the original error as the one raised.
# Removal is best-effort — a failing step must not mask the
# setup error that triggered it.
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
# The SSE subscription (started just before the runtime record)
# is stopped first: like the coordinator it must never outlive
# the database being closed below.
if event_stream is not None:
try:
await event_stream.stop()
except BaseException:
pass
if remove_update_listener is not None:
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
try:
remove_update_listener()
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException:
pass
# A coordinator created before the failure holds entity
# listeners pointed at the never-registered runtime — shut it
# down so no scheduled refresh outlives the failed setup.
# listeners pointed at the database being closed below —
# shut it down FIRST so no scheduled refresh can ever run
# against a closed connection.
if coordinator is not None:
try:
await coordinator.async_shutdown()
@@ -199,8 +606,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
pass
# A platform-forward failure reached this unwind AFTER
# runtime_data was assigned: clear it so no later unload path
# re-invokes the already-consumed update-listener remover
# through the stale record.
# closes the already-closed database through the stale record.
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
# The runtime record was ALSO registered in hass.data before
@@ -209,7 +615,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# (hass.data wins over entry.runtime_data there) and
# re-invokes the already-consumed update-listener remover.
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
await database.close()
raise
assert remove_update_listener is not None
assert remove_time_change_listener is not None
# The runtime record was already registered before the first
# refresh; re-assert the (unchanged) record so the return path
# stays readable as one registration site.
@@ -227,17 +636,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
# Platforms unload FIRST (mirroring HA conventions): entity
# listeners must not outlive the coordinator the teardown below
# shuts down.
# listeners must not outlive the database and coordinator the
# teardown below closes.
platforms_unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if platforms_unloaded is False and runtime_data is not None:
# Home Assistant KEPT one or more platforms loaded — their
# entities still live and must keep their coordinator. Restore
# entities still live and must keep their database. Restore
# the runtime record popped above and report failure so HA
# retries the unload later instead of leaving live entities
# backed by a torn-down coordinator.
# backed by a closed database.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
return False
unload_error: BaseException | None = None
@@ -249,28 +658,43 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
stop_stream = getattr(event_stream, "stop", None)
if stop_stream is not None:
await stop_stream()
remove_update_listener = getattr(
runtime_data, "remove_update_listener", None
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
)
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# A failing remover must not skip the coordinator shutdown
# below; the first error is retained and re-raised only
# after the teardown ran.
if unload_error is None:
unload_error = err
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
finally:
# The coordinator's entity listeners must stop here: a
# scheduled refresh must never outlive the unload.
# The coordinator's entity listeners must stop BEFORE the
# database closes: a scheduled refresh starting during the
# close await would touch a cleared connection. (The
# platform unload above already removed the entities; this
# clears the coordinator's own remaining listeners.)
coordinator = getattr(runtime_data, "coordinator", None)
if coordinator is not None:
shutdown = getattr(coordinator, "async_shutdown", None)
if shutdown is not None:
await shutdown()
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
# Domain-global services are torn down only once the FINAL entry
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
# entry is still loaded.
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package.
The admin allowlist business layer lives in
:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in
task 510c1f78); this module re-exports it so existing
``from .admin_allowlist import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.admin_allowlist import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package.
The children registry business layer lives in
:mod:`custom_components.nestquest.core.children` (extracted in
task 510c1f78); this module re-exports it so existing
``from .children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.children import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package.
The completion business layer lives in
:mod:`custom_components.nestquest.core.completion` (extracted in
task 510c1f78); this module re-exports it so existing
``from .completion import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.completion import * # noqa: F401,F403
+15 -7
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
from .const import CONF_ADMIN_USER_IDS, DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> FlowResult:
"""Handle the user step of the config flow.
The integration needs no configuration to start (the API
connection is set in the options flow), so the user input is
stored as-is. The former admin-allowlist copy is gone: with
the database removed the allowlist lives in the API service's
own store, not on the entry.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
@@ -43,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="NestQuest", data=dict(user_input))
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
+25 -22
View File
@@ -20,41 +20,44 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
# Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The
# panel and admin cards listen for these; since Feature 18 the
# completed/uncompleted/day-complete transitions are published by the
# API service on its SSE stream and re-fired on the bus by the
# integration's subscription, and Feature 11's nightly sweep fires
# quest_missed.
# panel and admin cards listen for these; Feature 10 fires completed,
# uncompleted, and child_day_complete from the service paths, and
# Feature 11's nightly sweep fires quest_missed.
EVENT_QUEST_COMPLETED = "nestquest_quest_completed"
EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted"
EVENT_QUEST_MISSED = "nestquest_quest_missed"
EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete"
#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2).
#: Since Feature 18 only the panel completion service remains: the admin
#: surface is the PWA and the API service's admin routes, so the former
#: HA admin services (uncomplete, quest-definition and presence
#: management, CSV export, child management, regenerate) are gone.
#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2
#: plus the existing Feature 07 regenerate service).
SERVICE_COMPLETE_QUEST = "complete_quest"
SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest"
SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition"
SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition"
SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active"
SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern"
SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override"
SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override"
SERVICE_EXPORT_HISTORY_CSV = "export_history_csv"
SERVICE_MANAGE_CHILD = "manage_child"
SERVICE_REGENERATE = "regenerate"
DOMAIN_SERVICES: tuple[str, ...] = (
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_UPDATE_QUEST_DEFINITION,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
)
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,46 @@
"""Explicit admin-only versus open service registry (Feature 09).
One mapping, one file: every registered ``nestquest`` service name is
either ``open`` or ``admin_only``. The permission decorator (later
Feature 09 tasks) reads this table; it does not take per-handler
policy arguments.
"""
from __future__ import annotations
from .const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
OPEN = "open"
ADMIN_ONLY = "admin_only"
SERVICE_POLICY: dict[str, str] = {
SERVICE_COMPLETE_QUEST: OPEN,
SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY,
SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY,
SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY,
SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY,
SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY,
SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY,
SERVICE_MANAGE_CHILD: ADMIN_ONLY,
SERVICE_REGENERATE: ADMIN_ONLY,
}
if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES):
raise RuntimeError(
"SERVICE_POLICY must name exactly the registered nestquest services, "
f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}"
)
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package.
The children/admin DAO lives in
:mod:`custom_components.nestquest.core.dao_children` (extracted in
task d0b691d8); this module re-exports it so existing
``from .dao_children import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_children import * # noqa: F401,F403
from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401
@@ -0,0 +1,12 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package.
The instances/completion-events DAO lives in
:mod:`custom_components.nestquest.core.dao_instances`
(extracted in task d0b691d8); this module re-exports it so existing
``from .dao_instances import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_instances import * # noqa: F401,F403
from .core.dao_instances import _resolve_today # noqa: F401
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package.
The meta-state DAO lives in
:mod:`custom_components.nestquest.core.dao_meta` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_meta import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_meta import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package.
The presence DAOs live in
:mod:`custom_components.nestquest.core.dao_presence` (extracted in
task d0b691d8); this module re-exports them so existing
``from .dao_presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_presence import * # noqa: F401,F403
+17
View File
@@ -0,0 +1,17 @@
"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package.
The schedule-rules/quest-definitions DAO lives in
:mod:`custom_components.nestquest.core.dao_rules` (extracted in task
d0b691d8); this module re-exports it so existing
``from .dao_rules import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.dao_rules import * # noqa: F401,F403
from .core.dao_rules import ( # noqa: F401
_UNSET,
_validate_date,
_validate_time,
_validate_window,
)
-215
View File
@@ -1,215 +0,0 @@
"""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
+31
View File
@@ -0,0 +1,31 @@
"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package.
The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task
d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes
an executor callable rather than a ``hass`` object. This module
re-exports it and provides :func:`make_database`, a thin adapter that
constructs a wrapper backed by ``hass.async_add_executor_job``, so the
integration constructs the database the same way it always did.
"""
from __future__ import annotations
from typing import Any, TypeVar
from homeassistant.core import HomeAssistant
from .core.db import ExecutionResult, NestQuestDatabase
__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"]
T = TypeVar("T")
def make_database(hass: HomeAssistant) -> NestQuestDatabase:
"""Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor.
Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the
wrapper's executor is ``hass.async_add_executor_job``, so every
sqlite3 call runs on the HA executor thread and the event loop
never blocks.
"""
return NestQuestDatabase(hass.async_add_executor_job)
+79
View File
@@ -0,0 +1,79 @@
"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package.
The transition-event payload construction lives in
:mod:`custom_components.nestquest.core.events` (extracted in
task 510c1f78) and is HA-free: it builds and RETURNS the events. This
shim keeps the integration's old ``fire_quest_completed`` /
``fire_quest_uncompleted`` API (the complete/uncomplete service
handlers call them with ``hass``), reads the HA-local timezone from
``hass.config.time_zone`` to derive ``today``, asks the core builder
for the events, and fires each on ``hass.bus`` with the documented
payloads. The four blueprints keep working with identical payloads.
The completed event is fired BEFORE the day-complete rule evaluates:
``fire_quest_completed`` asks
:func:`build_quest_completed_event` for the completed event and fires
it immediately, THEN reads the HA-local ``today``, asks
:func:`build_child_day_complete_event` for the day-complete event, and
fires it (if any) afterwards. This restores the pre-extraction
ordering — a failure in the day-complete DB reads or the ZoneInfo
lookup cannot suppress the completed event.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from .core.db import NestQuestDatabase
from .core.events import (
build_child_day_complete_event,
build_quest_completed_event,
build_quest_uncompleted_events,
)
__all__ = ["fire_quest_completed", "fire_quest_uncompleted"]
async def fire_quest_completed(
hass,
database: NestQuestDatabase,
instance_id: int,
*,
was_on_time: bool | None,
) -> None:
"""Fire ``nestquest_quest_completed`` and, when the completion
cleared the child's whole day, ``nestquest_child_day_complete``.
Thin HA wiring over the HA-free
:func:`custom_components.nestquest.core.events.build_quest_completed_event`
and
:func:`build_child_day_complete_event`: the completed event is
built and fired FIRST (so a failure in the day-complete DB reads
or the HA-local ``today`` lookup cannot suppress it), then the
HA-local ``today`` (the day-complete rule's input) is read from
``hass.config.time_zone`` and the day-complete builder is asked
whether the child's whole day cleared. Identical payloads and
day-complete rule to the pre-extraction implementation.
"""
completed = await build_quest_completed_event(
database,
instance_id,
was_on_time=was_on_time,
)
hass.bus.async_fire(*completed)
today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
day_complete = await build_child_day_complete_event(
database, completed[1], today=today
)
if day_complete is not None:
hass.bus.async_fire(*day_complete)
async def fire_quest_uncompleted(
hass, database: NestQuestDatabase, instance_id: int
) -> None:
"""Fire ``nestquest_quest_uncompleted`` for an actual reversal."""
events = await build_quest_uncompleted_events(database, instance_id)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
+1 -2
View File
@@ -7,8 +7,7 @@
"config_flow": true,
"dependencies": [
"frontend",
"http",
"lovelace"
"http"
],
"documentation": "https://github.com/Maniacs-Mansion/NestQuest",
"integration_type": "hub",
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package.
The materialization walk lives in
:mod:`custom_components.nestquest.core.materialize` (extracted in
task 510c1f78); this module re-exports it so existing
``from .materialize import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.materialize import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package.
The versioned migration runner lives in
:mod:`custom_components.nestquest.core.migrations`
(extracted in task d0b691d8); this module re-exports it so existing
``from .migrations import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.migrations import * # noqa: F401,F403
+315 -59
View File
@@ -1,15 +1,4 @@
"""Options flow for NestQuest post-setup settings.
Since the DB removal (Feature 18) the integration is a thin client of
the NestQuest API service: the settings that only served the local
database (the generation horizon, the day rollover, the notification
defaults, the admin allowlist) are gone — the API service owns those in
its own settings store, and the automations take their behaviour from
blueprint inputs. What remains is what the CLIENT itself consumes: the
panel-plane API connection (base URL + token), the shared coordinator's
refresh interval, the last-good staleness threshold, and the
panel-facing idle timeout.
"""
"""Options flow for NestQuest post-setup settings."""
from __future__ import annotations
from typing import Any
@@ -17,25 +6,52 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADMIN_USER_IDS,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_API_BASE_URL,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_API_BASE_URL,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MIN_UPDATE_INTERVAL,
TIME_PATTERN,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
from .api_client import is_valid_base_url
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
def _parse_time(value: str) -> bool:
"""Return True when value is a valid 24-hour HH:MM string."""
return TIME_PATTERN.fullmatch(value) is not None
def _async_validate(
user_input: dict[str, Any],
current: dict[str, Any] | None = None,
@@ -50,6 +66,18 @@ def _async_validate(
"""
errors: dict[str, str] = {}
horizon_days = user_input.get(CONF_HORIZON_DAYS)
if (
isinstance(horizon_days, bool)
or not isinstance(horizon_days, int)
or horizon_days < MIN_HORIZON_DAYS
):
errors[CONF_HORIZON_DAYS] = "invalid"
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
if (
isinstance(panel_idle_timeout, bool)
@@ -97,6 +125,41 @@ def _async_validate(
if staleness < effective_interval:
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
# The notification section (Feature 11) is OPTIONAL in raw submits
# the same way: a missing key keeps the current value. The three
# times are strict HH:MM; the notify target is a non-empty HA
# service name; the toggles are real booleans.
for time_key in (
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
):
time_value = user_input.get(time_key)
if time_value is not None and (
not isinstance(time_value, str) or not _parse_time(time_value)
):
errors[time_key] = "invalid_time"
notify_target = user_input.get(CONF_NOTIFY_TARGET)
if notify_target is not None and not isinstance(notify_target, str):
errors[CONF_NOTIFY_TARGET] = "invalid"
elif isinstance(notify_target, str) and notify_target != (
notify_target.strip()
):
# An EMPTY target is valid — it means "no notifications
# configured yet" and is the shipped default; a value that
# differs from its own trim (padded or whitespace-only) is
# malformed and rejected.
errors[CONF_NOTIFY_TARGET] = "invalid"
for toggle_key in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
toggle_value = user_input.get(toggle_key)
if toggle_value is not None and not isinstance(toggle_value, bool):
errors[toggle_key] = "invalid"
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
# submits the same way: a missing key keeps the current value. The
# base URL must be a full http(s) URL with a host — the same check
@@ -119,78 +182,261 @@ def _async_validate(
return errors
def _build_schema(current: dict[str, Any]) -> vol.Schema:
def _strict_bool(value: object) -> bool:
"""Require a real bool; voluptuous ``bool`` would coerce strings."""
if not isinstance(value, bool):
raise vol.Invalid(f"expected a boolean, got {value!r}")
return value
def _build_schema(
current: dict[str, Any],
*,
admin_choices: dict[str, str] | None = None,
admin_default: list[str] | None = None,
) -> vol.Schema:
"""Build the form schema, pre-filled with the current values.
The API connection (Feature 18) carries the base URL the API client
talks to and the panel service token it presents as the Bearer
credential. Schema-validated submits always carry every field (the
defaults pre-fill from the stored values); raw callers omitting one
keep its stored value (see _async_validate).
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed. The notification section
(Feature 11) carries the household's notify target, the three
automation times, and four enable toggles — defaults from
:mod:`~.const`, never a hard-coded personal target.
"""
return vol.Schema(
{
vol.Required(
CONF_PANEL_IDLE_TIMEOUT,
default=current[CONF_PANEL_IDLE_TIMEOUT],
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
# The last-good staleness threshold (Feature 18): how long
# the coordinator keeps serving the cached snapshot after
# the API stops answering.
vol.Required(
CONF_SNAPSHOT_STALENESS,
default=current[CONF_SNAPSHOT_STALENESS],
): int,
# Panel-plane API connection (Feature 18): the base URL the
# API client talks to and the panel service token it
# presents as the Bearer credential.
vol.Required(
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
): str,
vol.Required(
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
): str,
}
)
fields: dict[Any, Any] = {
vol.Required(
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
): int,
vol.Required(
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
): str,
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
vol.Required(
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
): int,
# The last-good staleness threshold (Feature 18): how long the
# coordinator keeps serving the cached snapshot after the API
# stops answering. Schema-validated submits always carry it
# (the default pre-fills from the stored value); raw callers
# omitting it keep their stored value (see _async_validate).
vol.Required(
CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS]
): int,
vol.Required(
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
): str,
vol.Required(
CONF_MORNING_SUMMARY_TIME,
default=current[CONF_MORNING_SUMMARY_TIME],
): str,
vol.Required(
CONF_AFTERNOON_REMINDER_TIME,
default=current[CONF_AFTERNOON_REMINDER_TIME],
): str,
vol.Required(
CONF_END_OF_DAY_REPORT_TIME,
default=current[CONF_END_OF_DAY_REPORT_TIME],
): str,
vol.Required(
CONF_MORNING_SUMMARY_ENABLED,
default=current[CONF_MORNING_SUMMARY_ENABLED],
): _strict_bool,
vol.Required(
CONF_AFTERNOON_REMINDER_ENABLED,
default=current[CONF_AFTERNOON_REMINDER_ENABLED],
): _strict_bool,
vol.Required(
CONF_END_OF_DAY_REPORT_ENABLED,
default=current[CONF_END_OF_DAY_REPORT_ENABLED],
): _strict_bool,
vol.Required(
CONF_CELEBRATION_ENABLED,
default=current[CONF_CELEBRATION_ENABLED],
): _strict_bool,
# Panel-plane API connection (Feature 18): the base URL the API
# client talks to and the panel service token it presents as
# the Bearer credential. Schema-validated submits always carry
# both (the defaults pre-fill from the stored values); raw
# callers omitting them keep their stored values (see
# _async_validate).
vol.Required(
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
): str,
vol.Required(
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
): str,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
# serialize the field as a real picker of the given choices.
fields[
vol.Optional(
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
)
] = cv.multi_select(admin_choices)
return vol.Schema(fields)
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
"""Handle the options flow for NestQuest."""
def _async_database(self):
"""Return the runtime database for this entry, or None.
Real HA only opens the options flow for an existing entry, so
the runtime record (and its database) exists; the None branch
covers direct construction in tests and degraded states.
"""
hass_data = getattr(self.hass, "data", None)
if not isinstance(hass_data, dict):
return None
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
database = getattr(record, "database", None)
return database if getattr(database, "connected", False) else None
async def _async_admin_choices(self) -> dict[str, str]:
"""Map HA user id -> display name for the picker.
Real Home Assistant always provides ``hass.auth``; a missing
one (direct construction in tests, degraded harness) degrades
to an empty picker rather than crashing the form.
"""
auth = getattr(self.hass, "auth", None)
if auth is None:
return {}
users = await auth.async_get_users()
return {
user.id: user.name
for user in users
if getattr(user, "id", None)
}
async def _async_current_admin_ids(self) -> list[str]:
"""Return the current allowlist, or [] without a database."""
database = self._async_database()
if database is None:
return []
return await list_admin_ids(database)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the NestQuest options."""
admin_choices = await self._async_admin_choices()
admin_default = await self._async_current_admin_ids()
if user_input is None:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
)
errors = _async_validate(user_input, self._current_values())
# The admin ids are validated only when the key is present:
# schema-validated submits always carry it (vol.Optional's
# default fills from the current allowlist), while raw callers
# omitting the key keep the field untouched.
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
if admin_ids_present and (
not isinstance(admin_ids, list)
or any(not isinstance(value, str) for value in admin_ids)
):
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and not admin_ids:
# An empty allowlist fails closed: saving zero admins is
# refused, never silently applied.
errors[CONF_ADMIN_USER_IDS] = "no_admins"
elif admin_ids_present and any(
value not in admin_choices for value in admin_ids
):
# The schema enforces membership for real HA submits; this
# backs the raw-input path up so an unknown id can never
# reach the allowlist.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
# set_admin_ids refuses duplicates with ValueError; a flow
# submission must surface that as a field error instead of
# an unhandled crash.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
# Every remaining key is stored the fall-back-to-current way, so
# raw submits that predate a section keep that section's stored
# values.
# Apply the picker's selection to the allowlist when the
# runtime database is reachable; the selection is always
# stored on the entry as the disaster-recovery copy.
database = self._async_database()
if database is not None and admin_ids_present:
await set_admin_ids(database, admin_ids)
data = {
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
CONF_UPDATE_INTERVAL: user_input.get(
CONF_UPDATE_INTERVAL,
self._current_values()[CONF_UPDATE_INTERVAL],
),
CONF_SNAPSHOT_STALENESS: user_input.get(
CONF_SNAPSHOT_STALENESS,
self._current_values()[CONF_SNAPSHOT_STALENESS],
),
}
# The notification section (Feature 11) stores the household's
# choices the same fall-back-to-current way, so raw submits
# that predate the section keep their stored values.
current = self._current_values()
data: dict[str, Any] = {}
for key in (
CONF_PANEL_IDLE_TIMEOUT,
CONF_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS,
CONF_API_BASE_URL,
CONF_PANEL_TOKEN,
for notification_key in (
CONF_NOTIFY_TARGET,
CONF_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
data[key] = user_input.get(key, current[key])
data[notification_key] = user_input.get(
notification_key, current[notification_key]
)
# The API connection section (Feature 18) stores the base URL
# and panel token the same fall-back-to-current way, so raw
# submits that predate the section keep their stored values.
for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
data[api_key] = user_input.get(api_key, current[api_key])
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
# HA replaces entry.options wholesale, so an omitted picker
# field must carry the CURRENT allowlist forward: dropping
# it would leave only the stale config-flow copy in entry
# data, and a later database loss would resurrect admins
# the owner has since removed.
data[CONF_ADMIN_USER_IDS] = list(admin_default)
return self.async_create_entry(title="NestQuest", data=data)
def _current_values(self) -> dict[str, Any]:
@@ -199,9 +445,19 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
entry_data = getattr(self.config_entry, "data", None) or {}
values: dict[str, Any] = {}
for key, default in (
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
(CONF_NOTIFY_TARGET, ""),
(CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME),
(CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME),
(CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME),
(CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED),
(CONF_API_BASE_URL, DEFAULT_API_BASE_URL),
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
):
@@ -211,4 +467,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
values[key] = entry_data[key]
else:
values[key] = default
return values
return values
@@ -0,0 +1,90 @@
"""Permission gate for NestQuest services (Feature 09).
A wrapper factory, applied to EVERY registered service at registration
time (no handler is exempt), reads ``call.context.user_id``, consults
the :data:`~.service_policy.SERVICE_POLICY` registry and
:func:`~.admin_allowlist.is_admin`, and raises
:class:`homeassistant.exceptions.Unauthorized` for an admin-only
operation called by a non-admin — including a call with NO user
context, which fails closed. ``complete_quest`` (policy ``open``)
proceeds regardless of the allowlist.
Logging (later Feature 09 tasks): every denied admin-only call logs a
warning and every successful one logs a debug line, both carrying the
service, the user id (or ``none``), and a UTC timestamp.
"""
from __future__ import annotations
import datetime
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from homeassistant.exceptions import Unauthorized
from .admin_allowlist import is_admin
from .service_policy import ADMIN_ONLY
LOGGER = logging.getLogger(__name__)
ServiceHandler = Callable[[Any], Awaitable[None]]
def _context_user_id(call: Any) -> str | None:
context = getattr(call, "context", None)
user_id = getattr(context, "user_id", None) if context is not None else None
if isinstance(user_id, str) and user_id:
return user_id
return None
def _stamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def permission_gate(
service: str,
policy: str,
database_of: Callable[[Any], Any],
) -> Callable[[ServiceHandler], ServiceHandler]:
"""Wrap ``handler`` with the Feature 09 permission check.
``database_of`` resolves the live database from the call-time
runtime (the same ``find_runtime`` the handler uses), so the gate
never opens its own connection. The verdict is fail-closed:
absent allowlist row, unknown id, no user context — all not admin.
"""
def _wrap(handler: ServiceHandler) -> ServiceHandler:
if policy != ADMIN_ONLY:
return handler
async def _gated(call: Any) -> None:
user_id = _context_user_id(call)
allowed = user_id is not None and await is_admin(
database_of(call), user_id
)
if not allowed:
LOGGER.warning(
"NestQuest denied %s: caller user_id=%s at %s",
service,
user_id if user_id is not None else "none",
_stamp(),
)
raise Unauthorized(
f"User '{user_id if user_id is not None else 'none'}' "
f"is not allowed to call nestquest.{service}"
)
LOGGER.debug(
"NestQuest allowed %s: caller user_id=%s at %s",
service,
user_id,
_stamp(),
)
await handler(call)
return _gated
return _wrap
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package.
The presence and custody model lives in
:mod:`custom_components.nestquest.core.presence` (extracted in
task 510c1f78); this module re-exports it so existing
``from .presence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.presence import * # noqa: F401,F403
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package.
The presence-write business layer lives in
:mod:`custom_components.nestquest.core.presence_management` (added for
task e4a9d31b); this module re-exports it so the integration's service
handlers call the ONE implementation the API admin plane shares.
"""
from __future__ import annotations
from .core.presence_management import * # noqa: F401,F403
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package.
The quest definitions business layer lives in
:mod:`custom_components.nestquest.core.quest_definitions` (extracted in
task 510c1f78); this module re-exports it so existing
``from .quest_definitions import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.quest_definitions import * # noqa: F401,F403
+10
View File
@@ -0,0 +1,10 @@
"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package.
The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence`
(extracted in task d0b691d8); this module re-exports it so existing
``from .recurrence import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.recurrence import * # noqa: F401,F403
+11
View File
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package.
The declarative schema DDL lives in
:mod:`custom_components.nestquest.core.schema` (extracted in
task d0b691d8); this module re-exports it so existing
``from .schema import ...`` references inside the integration keep
working.
"""
from __future__ import annotations
from .core.schema import * # noqa: F401,F403
+3 -66
View File
@@ -21,8 +21,6 @@ missed instances, and ``admin_instances`` includes them (D-009).
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterable
from typing import Any
@@ -43,18 +41,13 @@ from .coordinator import (
)
from .core.snapshot import instance_payload
#: HA's state machine refuses states longer than 255
#: Home Assistant's state machine refuses states longer than 255
#: characters (its hard recorder/state limit). These sensors' states
#: are numeric, but the guard is applied uniformly: a payload-bearing
#: sensor can never crash the state write, and later string-state
#: sensors reuse the same helper.
MAX_STATE_LENGTH = 255
#: Entity-id length cap the slug mirror applies: HA truncates the
#: entity_id (``sensor.`` + slug) at MAX_LENGTH_STATE_ENTITY_ID (64),
#: so the slugged object id alone may not exceed 64 - len("sensor.").
_MAX_SLUG_LENGTH = 64 - len("sensor.")
def _state_within_limit(state: Any) -> Any:
"""Return ``state`` unchanged, refusing strings over HA's limit.
@@ -105,56 +98,6 @@ def _next_open_quest(
)
def _slugify(text: str) -> str:
"""Slugify a display name the way Home Assistant derives entity ids.
Mirrors ``homeassistant.util.slugify`` (the unicode path HA runs
entity names through) for accented-Latin names: NFKD-normalize,
fold to ASCII (accents drop out with their combining marks),
lowercase, and turn every run of non-alphanumerics into one
underscore, capped at HA's entity-id length like the registry does.
The documented divergence from HA's vendored slugifier is unidecode:
HA transliterates any script to ASCII (so e.g. non-Latin names
survive), this mirror drops those characters instead, and a name
that then slugs to nothing falls through to HA's ``unknown``
sentinel — the same answer HA's wrapper gives. (The harness is
mock-only — no real HA to import the helper from — and entity ids
are registry identity, so the mirror is deliberate.)
"""
slug = unicodedata.normalize("NFKD", text)
slug = slug.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "_", slug.lower()).strip("_")
return (slug or "unknown")[:_MAX_SLUG_LENGTH]
def _child_roster(children: Iterable[ChildDaySnapshot]) -> list[dict[str, Any]]:
"""The ordered child roster the panel cards discover children from.
One entry per ACTIVE child in the snapshot's order (the API's
sort_order, then id): ``child_id``, ``name``, and the ``slug`` the
entity-id derivation produces for the child's name — the piece the
cards need to build ``sensor.nestquest_<slug>_...`` ids. Duplicate
display names get the registry's ``_2``/``_3`` suffix in snapshot
order, matching how HA resolves the collision at registration. The
rollups' ``children`` attribute (per-child counts) is unchanged and
carries a different shape on purpose.
"""
slugs: dict[str, int] = {}
roster: list[dict[str, Any]] = []
for child in children:
base = _slugify(child.child_name)
taken = slugs.get(base, 0) + 1
slugs[base] = taken
roster.append(
{
"child_id": child.child_id,
"name": child.child_name,
"slug": base if taken == 1 else f"{base}_{taken}",
}
)
return roster
class _NestQuestChildDaySensor(CoordinatorEntity, SensorEntity):
"""Base for the per-child day sensors.
@@ -413,12 +356,7 @@ class _NestQuestHouseholdSensor(CoordinatorEntity, SensorEntity):
class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"""Total quests due today across active children.
Also publishes ``child_roster`` (see :func:`_child_roster`): the
ordered child identity list the panel cards read to discover the
board's children when the card config carries no ``child_order``.
"""
"""Total quests due today across active children."""
_attr_name = "NestQuest household quests due today"
_attr_state_class = SensorStateClass.MEASUREMENT
@@ -442,8 +380,7 @@ class NestQuestHouseholdDueTodaySensor(_NestQuestHouseholdSensor):
"children": {
str(child.child_id): child.due_today
for child in data.children
},
"child_roster": _child_roster(data.children),
}
}
@@ -0,0 +1,11 @@
"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package.
The service policy registry lives in
:mod:`custom_components.nestquest.core.service_policy` (extracted in
task 510c1f78); this module re-exports it so existing
``from .service_policy import ...`` references inside the integration
keep working.
"""
from __future__ import annotations
from .core.service_policy import * # noqa: F401,F403
+476 -25
View File
@@ -1,36 +1,65 @@
"""The domain-global ``nestquest.complete_quest`` service (Feature 18).
"""Domain-global NestQuest service schemas and handlers (Feature 09).
Registration is domain-scoped and happens once (see
:func:`async_register_services`). Since Feature 18 the integration is
a client of the NestQuest API service, so ``complete_quest`` proxies
to the API's panel complete route through the entry's shared API
client instead of writing any database. The former HA admin services
(uncomplete, quest-definition and presence management, CSV export,
child management, regenerate) are removed: the admin surface is the
PWA and the API service's admin routes, so the only HA service left is
the panel completion path.
:func:`async_register_services`); handlers resolve a live database at
call time through the injected ``find_runtime`` callable. Permission
checks are Feature 09 later tasks — this module only validates payload
shape and forwards to the Feature 03/06/08 business layers (the
presence writes included, through :mod:`.presence_management` — the
ONE implementation the API admin plane shares). Since Feature 18 the
integration is a client of the NestQuest API service, so
``complete_quest`` proxies to the API's panel complete route through
the entry's shared API client instead of writing any database.
``export_history_csv`` is registered with a schema but raises until
Feature 13. ``regenerate`` is supplied by the caller so
the Feature 07 handler stays the one in ``__init__``.
"""
from __future__ import annotations
import datetime
from collections.abc import Awaitable, Callable
from typing import Any
from zoneinfo import ZoneInfo
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import children as children_layer
from . import completion as completion_layer
from . import presence_management as presence_management_layer
from . import quest_definitions as quest_definitions_layer
from .api_client import NestQuestApiError
from .const import (
DOMAIN,
DOMAIN_SERVICES,
QUEST_WINDOWS,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from .events import fire_quest_uncompleted
from .permissions import permission_gate
from .recurrence import ScheduleRule
from .service_policy import SERVICE_POLICY
FindRuntime = Callable[[HomeAssistant], Any]
ServiceHandler = Callable[[Any], Awaitable[None]]
ACTOR_PANEL = "panel"
ACTOR_USER = "user"
MANAGE_CHILD_CREATE = "create"
MANAGE_CHILD_EDIT = "edit"
MANAGE_CHILD_SET_ACTIVE = "set_active"
MANAGE_CHILD_REORDER = "reorder"
def _strict_int(value: object) -> int:
@@ -40,6 +69,82 @@ def _strict_int(value: object) -> int:
return value
def _strict_bool(value: object) -> bool:
"""Reject ints and other truthy values; require a real bool."""
if type(value) is not bool:
raise vol.Invalid(f"expected bool, got {value!r}")
return value
def _weekday_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of weekday ints, got {value!r}")
return [_strict_int(item) for item in value]
def _presence_pattern(value: object) -> dict[int, list[int]]:
if not isinstance(value, dict):
raise vol.Invalid(f"pattern must be a mapping, got {value!r}")
pattern: dict[int, list[int]] = {}
for week, days in value.items():
if isinstance(week, str):
try:
week_index = int(week)
except ValueError as err:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
) from err
if str(week_index) != week:
raise vol.Invalid(
f"pattern week index must be an integer, got {week!r}"
)
else:
week_index = _strict_int(week)
pattern[week_index] = _weekday_list(days)
return pattern
def _window_entry(value: object) -> str | tuple[str, str | None]:
if isinstance(value, str):
if value not in QUEST_WINDOWS:
raise vol.Invalid(f"unknown window {value!r}")
return value
if isinstance(value, dict):
parsed = vol.Schema(
{
vol.Required("window"): vol.In(QUEST_WINDOWS),
vol.Optional("due_time"): str,
}
)(value)
due_time = parsed.get("due_time")
if due_time is None:
return parsed["window"]
return (parsed["window"], due_time)
raise vol.Invalid(
f"windows entry must be a window name or object, got {value!r}"
)
def _windows(value: object) -> list[str | tuple[str, str | None]]:
if type(value) is not list:
raise vol.Invalid(f"windows must be a list, got {value!r}")
if not value:
raise vol.Invalid("windows must not be empty")
return [_window_entry(entry) for entry in value]
def _int_list(value: object) -> list[int]:
if type(value) is not list:
raise vol.Invalid(f"expected a list of ints, got {value!r}")
return [_strict_int(item) for item in value]
def _rule_dict(value: object) -> dict:
if not isinstance(value, dict):
raise vol.Invalid(f"rule must be a dict, got {value!r}")
return value
SCHEMA_COMPLETE_QUEST = vol.Schema(
{
vol.Required("instance_id"): _strict_int,
@@ -49,22 +154,102 @@ SCHEMA_COMPLETE_QUEST = vol.Schema(
}
)
SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST
SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("title"): str,
vol.Required("rule"): _rule_dict,
vol.Required("assignee_child_ids"): vol.All(
_int_list, vol.Length(min=1)
),
vol.Required("windows"): _windows,
vol.Optional("description"): str,
vol.Optional("icon"): str,
}
)
SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Optional("title"): str,
vol.Optional("description"): vol.Any(str, None),
vol.Optional("icon"): vol.Any(str, None),
vol.Optional("rule"): _rule_dict,
vol.Optional("windows"): _windows,
}
)
SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema(
{
vol.Required("definition_id"): _strict_int,
vol.Required("is_active"): _strict_bool,
}
)
SCHEMA_SET_PRESENCE_PATTERN = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("cycle_length_weeks"): _strict_int,
vol.Required("anchor_date"): str,
vol.Required("pattern"): _presence_pattern,
}
)
SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("child_id"): _strict_int,
vol.Required("start_date"): str,
vol.Required("end_date"): str,
vol.Required("is_present"): _strict_bool,
vol.Optional("note"): str,
}
)
SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema(
{
vol.Required("override_id"): _strict_int,
}
)
SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({})
SCHEMA_MANAGE_CHILD = vol.Schema(
{
vol.Required("action"): vol.In(
[
MANAGE_CHILD_CREATE,
MANAGE_CHILD_EDIT,
MANAGE_CHILD_SET_ACTIVE,
MANAGE_CHILD_REORDER,
]
),
vol.Optional("child_id"): _strict_int,
vol.Optional("display_name"): str,
vol.Optional("colour"): str,
vol.Optional("avatar_ref"): str,
vol.Optional("sort_order"): _strict_int,
vol.Optional("is_active"): _strict_bool,
vol.Optional("ordered_ids"): _int_list,
}
)
def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
runtime = find_runtime(hass)
if runtime is None:
raise HomeAssistantError(
"NestQuest is not loaded; cannot run this service"
"NestQuest has no live database; cannot run this service"
)
return runtime
async def _refresh_entities(runtime: Any) -> None:
"""Force an immediate coordinator refresh after the service call.
"""Force an immediate coordinator refresh after a service mutation.
Only the SERVICE path calls this — the scheduled materialize and
Only the SERVICE paths call this — the scheduled materialize and
rollover internals are direct function calls and never widen this
gate — so the entities reflect a completion the moment the service
gate — so entities reflect a completion the moment the service
call returns instead of waiting for the poll interval.
"""
coordinator = getattr(runtime, "coordinator", None)
@@ -72,6 +257,12 @@ async def _refresh_entities(runtime: Any) -> None:
await coordinator.async_refresh()
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
return {"today": today, "horizon_days": runtime.settings.horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]:
actor = call.data["actor"]
if actor == ACTOR_PANEL:
@@ -162,29 +353,289 @@ def _complete_quest(
return _with_errors(_handler)
def _uncomplete_quest(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
instance_id = call.data["instance_id"]
result = await completion_layer.uncomplete_instance(
runtime.database,
instance_id,
**_actor_kwargs(call),
)
if result.appended:
await fire_quest_uncompleted(
hass, runtime.database, instance_id
)
await _refresh_entities(runtime)
return _with_errors(_handler)
def _create_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
await quest_definitions_layer.create_quest_definition(
database,
data["title"],
ScheduleRule.from_dict(data["rule"]),
data["assignee_child_ids"],
data["windows"],
description=data.get("description"),
icon=data.get("icon"),
)
return _with_errors(_handler)
def _update_quest_definition(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
kwargs: dict[str, Any] = _generation_kwargs(hass, runtime)
if "title" in data:
kwargs["title"] = data["title"]
if "description" in data:
kwargs["description"] = data["description"]
if "icon" in data:
kwargs["icon"] = data["icon"]
if "rule" in data:
kwargs["rule"] = ScheduleRule.from_dict(data["rule"])
if "windows" in data:
kwargs["windows"] = data["windows"]
await quest_definitions_layer.edit_quest_definition(
runtime.database, data["definition_id"], **kwargs
)
return _with_errors(_handler)
def _set_quest_definition_active(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await quest_definitions_layer.set_quest_definition_active(
runtime.database,
call.data["definition_id"],
call.data["is_active"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _set_presence_pattern(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await presence_management_layer.set_presence_schedule(
runtime.database,
data["child_id"],
data["cycle_length_weeks"],
data["anchor_date"],
data["pattern"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _create_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
data = call.data
await presence_management_layer.create_presence_override(
runtime.database,
data["child_id"],
data["start_date"],
data["end_date"],
data["is_present"],
note=data.get("note"),
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
def _delete_presence_override(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
runtime = _require_runtime(hass, find_runtime)
await presence_management_layer.delete_presence_override(
runtime.database,
call.data["override_id"],
**_generation_kwargs(hass, runtime),
)
return _with_errors(_handler)
async def _export_history_csv(_call: Any) -> None:
raise HomeAssistantError(
"CSV history export is not implemented until Feature 13"
)
def _manage_child(
hass: HomeAssistant, find_runtime: FindRuntime
) -> ServiceHandler:
async def _handler(call: Any) -> None:
database = _require_runtime(hass, find_runtime).database
data = call.data
action = data["action"]
if action == MANAGE_CHILD_CREATE:
display_name = data.get("display_name")
if display_name is None:
raise HomeAssistantError(
"display_name is required when action is create"
)
kwargs: dict[str, Any] = {}
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.create_child(
database, display_name, **kwargs
)
return
if action == MANAGE_CHILD_EDIT:
child_id = data.get("child_id")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is edit"
)
kwargs = {}
if "display_name" in data:
kwargs["display_name"] = data["display_name"]
if "colour" in data:
kwargs["colour"] = data["colour"]
if "avatar_ref" in data:
kwargs["avatar_ref"] = data["avatar_ref"]
if "sort_order" in data:
kwargs["sort_order"] = data["sort_order"]
await children_layer.edit_child(database, child_id, **kwargs)
return
if action == MANAGE_CHILD_SET_ACTIVE:
child_id = data.get("child_id")
is_active = data.get("is_active")
if child_id is None:
raise HomeAssistantError(
"child_id is required when action is set_active"
)
if is_active is None:
raise HomeAssistantError(
"is_active is required when action is set_active"
)
await children_layer.set_child_active(
database, child_id, is_active
)
return
ordered_ids = data.get("ordered_ids")
if ordered_ids is None:
raise HomeAssistantError(
"ordered_ids is required when action is reorder"
)
await children_layer.reorder_children(database, ordered_ids)
return _with_errors(_handler)
def async_register_services(
hass: HomeAssistant,
*,
find_runtime: FindRuntime,
regenerate_handler: ServiceHandler,
) -> None:
"""Register the panel completion service once at domain scope.
"""Register every canonical service once at domain scope.
The one remaining HA service is the panel completion path; every
admin operation moved to the API service's admin routes, so there
is no permission gate to apply here.
Every service — regenerate included — passes through the Feature
09 permission gate before its handler runs; the gate reads
:data:`~.service_policy.SERVICE_POLICY`, so no service can be
registered exempt from the policy.
"""
if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST):
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
hass.services.async_register(
DOMAIN,
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
schema=SCHEMA_COMPLETE_QUEST,
)
gate = permission_gate
specs: list[tuple[str, ServiceHandler, object]] = [
(SERVICE_REGENERATE, regenerate_handler, None),
(
SERVICE_COMPLETE_QUEST,
_complete_quest(hass, find_runtime),
SCHEMA_COMPLETE_QUEST,
),
(
SERVICE_UNCOMPLETE_QUEST,
_uncomplete_quest(hass, find_runtime),
SCHEMA_UNCOMPLETE_QUEST,
),
(
SERVICE_CREATE_QUEST_DEFINITION,
_create_quest_definition(hass, find_runtime),
SCHEMA_CREATE_QUEST_DEFINITION,
),
(
SERVICE_UPDATE_QUEST_DEFINITION,
_update_quest_definition(hass, find_runtime),
SCHEMA_UPDATE_QUEST_DEFINITION,
),
(
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
_set_quest_definition_active(hass, find_runtime),
SCHEMA_SET_QUEST_DEFINITION_ACTIVE,
),
(
SERVICE_SET_PRESENCE_PATTERN,
_set_presence_pattern(hass, find_runtime),
SCHEMA_SET_PRESENCE_PATTERN,
),
(
SERVICE_CREATE_PRESENCE_OVERRIDE,
_create_presence_override(hass, find_runtime),
SCHEMA_CREATE_PRESENCE_OVERRIDE,
),
(
SERVICE_DELETE_PRESENCE_OVERRIDE,
_delete_presence_override(hass, find_runtime),
SCHEMA_DELETE_PRESENCE_OVERRIDE,
),
(
SERVICE_EXPORT_HISTORY_CSV,
_export_history_csv,
SCHEMA_EXPORT_HISTORY_CSV,
),
(
SERVICE_MANAGE_CHILD,
_manage_child(hass, find_runtime),
SCHEMA_MANAGE_CHILD,
),
]
def _database_of(_call: Any) -> Any:
return _require_runtime(hass, find_runtime).database
for service, handler, schema in specs:
gated = gate(
service, SERVICE_POLICY[service], _database_of
)(handler)
hass.services.async_register(DOMAIN, service, gated, schema=schema)
def async_deregister_services(hass: HomeAssistant) -> None:
"""Remove every canonical service when the last entry unloads."""
for service in DOMAIN_SERVICES:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
hass.services.async_remove(DOMAIN, service)
+291 -4
View File
@@ -1,8 +1,15 @@
# Service declarations for the NestQuest integration (Home Assistant
# services.yaml metadata). Services are domain-scoped: nested under the
# "nestquest" domain, not repeated per config entry. Since Feature 18
# only the panel completion service remains — every admin operation
# moved to the PWA and the API service's admin routes.
# "nestquest" domain, not repeated per config entry.
regenerate:
name: Regenerate quest instances
description: >-
Regenerate every future quest instance for the household over the rolling
horizon, using the same idempotent materialization as the daily rollover
and the startup backfill. Safe to call at any time: a re-run refreshes the
same rows instead of duplicating them, and completed instances are never
touched.
complete_quest:
name: Complete quest
@@ -36,4 +43,284 @@ complete_quest:
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
text:
uncomplete_quest:
name: Uncomplete quest
description: Undo a quest instance completion.
fields:
instance_id:
name: Instance ID
description: The quest instance to uncomplete.
required: true
selector:
number:
mode: box
actor:
name: Actor
description: Who is undoing the completion (panel or user).
required: true
selector:
select:
options:
- panel
- user
actor_child_id:
name: Actor child ID
description: The tapped child profile. Required when actor is panel.
required: false
selector:
number:
mode: box
actor_user_id:
name: Actor user ID
description: Home Assistant user id when actor is user and no call context is present.
required: false
selector:
text:
create_quest_definition:
name: Create quest definition
description: Create a quest definition with its schedule rule, assignees and windows.
fields:
title:
name: Title
description: Display title for the quest.
required: true
selector:
text:
rule:
name: Rule
description: Recurrence rule as a mapping (rule_type plus shape fields).
required: true
selector:
object:
assignee_child_ids:
name: Assignee child IDs
description: Child ids this definition is assigned to.
required: true
selector:
object:
windows:
name: Windows
description: Day windows (morning, afternoon, evening), optionally with a due time.
required: true
selector:
object:
description:
name: Description
description: Optional longer description.
required: false
selector:
text:
icon:
name: Icon
description: Optional icon name.
required: false
selector:
text:
update_quest_definition:
name: Update quest definition
description: Edit a quest definition's title, description, icon, rule or windows.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
title:
name: Title
description: Replacement display title.
required: false
selector:
text:
description:
name: Description
description: Replacement description, or null to clear.
required: false
selector:
text:
icon:
name: Icon
description: Replacement icon, or null to clear.
required: false
selector:
text:
rule:
name: Rule
description: Replacement recurrence rule as a mapping.
required: false
selector:
object:
windows:
name: Windows
description: Replacement day windows.
required: false
selector:
object:
set_quest_definition_active:
name: Set quest definition active
description: Deactivate or reactivate a quest definition.
fields:
definition_id:
name: Definition ID
description: The quest definition to update.
required: true
selector:
number:
mode: box
is_active:
name: Active
description: Whether the definition should generate future instances.
required: true
selector:
boolean:
set_presence_pattern:
name: Set presence pattern
description: Upsert a child's repeating presence schedule.
fields:
child_id:
name: Child ID
description: The child whose schedule is being set.
required: true
selector:
number:
mode: box
cycle_length_weeks:
name: Cycle length weeks
description: Number of weeks in the repeating cycle (1 to 4).
required: true
selector:
number:
mode: box
min: 1
max: 4
anchor_date:
name: Anchor date
description: Cycle anchor as a YYYY-MM-DD date.
required: true
selector:
date:
pattern:
name: Pattern
description: Mapping of week index to present weekday ints (Monday is 0).
required: true
selector:
object:
create_presence_override:
name: Create presence override
description: Create a date-range presence override for a child.
fields:
child_id:
name: Child ID
description: The child the override applies to.
required: true
selector:
number:
mode: box
start_date:
name: Start date
description: Inclusive start date (YYYY-MM-DD).
required: true
selector:
date:
end_date:
name: End date
description: Inclusive end date (YYYY-MM-DD).
required: true
selector:
date:
is_present:
name: Present
description: Whether the child is present for this range.
required: true
selector:
boolean:
note:
name: Note
description: Optional note stored with the override.
required: false
selector:
text:
delete_presence_override:
name: Delete presence override
description: Delete a presence override by id.
fields:
override_id:
name: Override ID
description: The override to delete.
required: true
selector:
number:
mode: box
export_history_csv:
name: Export history CSV
description: Export completion history as CSV. Not implemented until Feature 13.
manage_child:
name: Manage child
description: Create, edit, activate or reorder child profiles.
fields:
action:
name: Action
description: create, edit, set_active, or reorder.
required: true
selector:
select:
options:
- create
- edit
- set_active
- reorder
child_id:
name: Child ID
description: Required for edit and set_active.
required: false
selector:
number:
mode: box
display_name:
name: Display name
description: Required for create; optional for edit.
required: false
selector:
text:
colour:
name: Colour
description: Optional colour string.
required: false
selector:
text:
avatar_ref:
name: Avatar
description: Optional avatar reference.
required: false
selector:
text:
sort_order:
name: Sort order
description: Optional panel sort order.
required: false
selector:
number:
mode: box
is_active:
name: Active
description: Required for set_active.
required: false
selector:
boolean:
ordered_ids:
name: Ordered IDs
description: Complete permutation of child ids. Required for reorder.
required: false
selector:
object:
+32
View File
@@ -0,0 +1,32 @@
"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package.
The path resolver lives in
:mod:`custom_components.nestquest.core.store` (extracted in task
d0b691d8) and is Home-Assistant-free:
:func:`custom_components.nestquest.core.store.async_get_db_path`
takes the full database path. This module re-exports it and provides
the original ``async_get_db_path(hass)`` signature so the integration
resolves the path from ``hass.config.path`` exactly as before.
"""
from __future__ import annotations
from pathlib import Path
from homeassistant.core import HomeAssistant
from .core.const import SQLITE_DB_FILENAME
from .core.store import async_get_db_path as _core_async_get_db_path
from .core.store import _ensure_dir
__all__ = ["async_get_db_path", "_ensure_dir"]
async def async_get_db_path(hass: HomeAssistant) -> Path:
"""Return the absolute path to the NestQuest SQLite database file.
Resolves the database file from ``hass.config.path`` and delegates to
:func:`custom_components.nestquest.core.store.async_get_db_path`,
which ensures the parent directory exists off the event loop via
:func:`asyncio.to_thread`.
"""
return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME))
+9 -3
View File
@@ -18,19 +18,25 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
"panel_token": "Panel service token",
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format.",
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
+72
View File
@@ -0,0 +1,72 @@
"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim.
The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the
watermark read → ``list_missed_unswept`` query → payload build →
watermark span, all inside the per-database sweep lock, shared with
the API plane. This module is the integration's thin adapter over it:
at the configured day-rollover time (the same HA-local listener that
re-materializes the rolling horizon) and once at setup it calls the
core's :func:`~.core.sweep.run_missed_sweep` and fires each returned
event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due
open instance with the documented payload. The core never touches
Home Assistant; the firing lives here.
Idempotency is the core's watermark: after a run, ``nestquest_meta_state``
records the HA-local date the sweep ran, and the next run only sweeps
instances due in ``[watermark, today)`` — a second run the same night
fires nothing new, and a run after days of downtime sweeps exactly the
accumulated window once. An instance swept as missed that is later
completed simply gains its completion event; the sweep is read-only
over the domain tables (``missed`` is derived state, never an
event_type — Feature 08's contract).
"""
from __future__ import annotations
import datetime
import logging
from zoneinfo import ZoneInfo
from .core.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep as _run_core_missed_sweep,
)
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"]
async def run_missed_sweep(
hass,
database: NestQuestDatabase,
*,
today: datetime.date | None = None,
) -> int:
"""Fire one missed event per unswept past-due open instance.
``today`` optionally pins the HA-local date (threaded like the
materialization and completion layers); when omitted it is read
from ``hass.config.time_zone``. Returns the number of events
fired. Fires nothing and returns 0 when the watermark already
covers ``today`` (a same-night rerun) — the watermark read, the
query, the payload build and the watermark write all live in the
core's locked
:func:`~.core.sweep.run_missed_sweep`; this shim only fires each
returned event on the bus and counts them.
"""
today_date = (
today
if today is not None
else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
)
events = await _run_core_missed_sweep(database, today=today_date)
for event_type, payload in events:
hass.bus.async_fire(event_type, payload)
fired = len(events)
LOGGER.info(
"NestQuest missed sweep for %s fired %d event(s)",
today_date.isoformat(),
fired,
)
return fired
@@ -18,19 +18,25 @@
"step": {
"init": {
"title": "NestQuest Settings",
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)",
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
"api_base_url": "NestQuest API base URL (http://host:port)",
"panel_token": "Panel service token"
"panel_token": "Panel service token",
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format.",
"invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.",
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval."
"invalid_staleness": "Last-good snapshot staleness must be at least the update interval.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
File diff suppressed because it is too large Load Diff
-522
View File
@@ -9,197 +9,10 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
"integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.0",
"@csstools/css-color-parser": "^4.2.3",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
"integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
"integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
"integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
"integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.1",
"@csstools/css-calc": "^3.4.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
"integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
"integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -642,24 +455,6 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz",
@@ -1097,79 +892,6 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1245,66 +967,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
"integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.0.0",
"@asamuzakjp/dom-selector": "^9.2.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.13",
"@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.10.2",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
@@ -1336,23 +998,6 @@
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
@@ -1372,19 +1017,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1434,26 +1066,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz",
@@ -1500,19 +1112,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1540,52 +1139,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.14.tgz",
"integrity": "sha512-EahQoi+Q5oqmG3yxRHx+bwn36OaLbtw6jnTFcGjc8fcmktzTgk45xRnzAx8Ch87PFEiqRhoc3FWBmY+LBrvEGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.14"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.14",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.14.tgz",
"integrity": "sha512-KYkjfJHnIC5t+Gy7hPrMHgJmWc/N9FfmS+fggRPSlpFckidpB9HD6OzPsSTkfDH+MpZgcu2tJPGgLfHl979N1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1600,16 +1153,6 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.11.0.tgz",
"integrity": "sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1684,71 +1227,6 @@
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.1.2",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
"integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
-1
View File
@@ -9,7 +9,6 @@
"lit": "^3.3.0"
},
"devDependencies": {
"jsdom": "^30.1.1",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
+7 -36
View File
@@ -143,16 +143,6 @@ function asNumber(value: unknown, fallback: number): number {
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) {
return 0;
@@ -902,34 +892,15 @@ export class NestQuestPartyBoardCard extends LitElement {
return [];
}
const order = this._config.child_order;
if (Array.isArray(order)) {
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
return this._discoveredChildSlugs();
}
/** Zero-config discovery (Feature 20): the ordered child roster the
* integration publishes on the household rollup
* (custom_components/nestquest/sensor.py — ``child_roster``, the
* snapshot's sort_order, then id), each entry carrying the slug the
* per-child entity ids are built from. Consulted only when the
* config has no child_order — explicit configuration wins. */
private _discoveredChildSlugs(): string[] {
const household = this._state(
"sensor.nestquest_household_quests_due_today"
);
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
if (!Array.isArray(order)) {
return [];
}
return roster
.map((entry) => asRosterSlug(entry))
.filter((slug) => slug.length > 0);
return order
.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0
)
.map((entry) => entry.trim());
}
private _plates(): ChildPlate[] {
-1
View File
@@ -1,7 +1,6 @@
import "./cards/party-board";
import "./cards/quest-log";
import "./cards/admin";
import "./strategy";
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
if (!document.querySelector('link[data-nq-fonts=""]')) {
-161
View File
@@ -1,161 +0,0 @@
import type { HassLike } from "./types";
type StateObject = {
state: unknown;
attributes?: Record<string, unknown> | null;
};
type RosterEntry = {
name: string;
slug: string;
};
type View = {
path?: string;
title?: string;
type: string;
cards: Record<string, unknown>[];
};
type LovelaceConfig = {
views: View[];
};
type StrategyConfig = Record<string, unknown>;
type StrategyClass = {
generate: (
config: StrategyConfig,
hass: HassLike | undefined
) => Promise<LovelaceConfig>;
};
declare global {
interface Window {
customStrategies?: Record<string, StrategyClass>;
}
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** The slug a discovered roster entry carries, or "" when the entry is
* not an object with a non-empty slug string — the same shape check
* the party board applies to the same attribute. */
function asRosterSlug(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const slug = (entry as Record<string, unknown>).slug;
return typeof slug === "string" ? slug.trim() : "";
}
/** The dashboard's URL path segment, used to COMPUTE the card paths the
* navigation needs. A dashboard lives at a single top-level segment
* (/<url_path>), so the first pathname segment is the dashboard root —
* this is where the strategy renders, and navigating back to it shows
* the first view (the party board). An explicit ``url_path`` in the
* strategy configuration wins for setups that need to pin it. */
function dashboardUrlPath(config: StrategyConfig): string {
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
if (configured) {
return configured;
}
const segment = window.location.pathname.split("/").filter(Boolean)[0];
return segment ?? "";
}
/** The display name a roster entry carries, or "" when it is not a
* non-empty string — the roster's ``name`` field (sensor.py
* ``_child_roster``), used for the quest-log view titles. */
function asRosterName(entry: unknown): string {
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
return "";
}
const name = (entry as Record<string, unknown>).name;
return typeof name === "string" ? name.trim() : "";
}
/** The ordered child roster the integration publishes on the household
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
* the snapshot's sort_order, then id). The same source the party
* board's plate discovery reads, consumed here at render time so the
* quest-log views exist for exactly the active children. */
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
const states = hass?.states as Record<string, StateObject> | undefined;
const household = states?.["sensor.nestquest_household_quests_due_today"];
const roster = household?.attributes?.child_roster;
if (!Array.isArray(roster)) {
return [];
}
return roster
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
.filter((entry) => entry.slug.length > 0);
}
/**
* Zero-config NestQuest dashboard strategy (Feature 20).
*
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
* gets its views GENERATED at render time from the household roster: one
* party-board view (the dashboard root) and one quest-log view per child,
* ordered by the roster's sort_order. The paths the cards navigate by are
* COMPUTED, not configured:
*
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
* - each log view's ``path`` IS the child's slug, so the log resolves its
* child from the last pathname segment (quest-log.ts
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
* root (quest-log.ts ``_returnToBoard``).
*
* With zero children the board view still renders — the board card shows
* its own "not set up yet" notice for an empty roster, so no log views are
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
* in the strategy configuration; everything else is derived.
*/
export class NestQuestPartyStrategy {
static async generate(
config: StrategyConfig,
hass: HassLike | undefined
): Promise<LovelaceConfig> {
const root = `/${dashboardUrlPath(config)}`;
const weatherEntity = asString(config.weather_entity);
const boardCard: Record<string, unknown> = {
type: "custom:nestquest-party-board-card",
quest_log_path: root,
};
if (weatherEntity) {
boardCard.weather_entity = weatherEntity;
}
const logCard: Record<string, unknown> = {
type: "custom:nestquest-quest-log-card",
board_path: root,
};
if (weatherEntity) {
logCard.weather_entity = weatherEntity;
}
return {
views: [
{
path: "party",
title: "The Party",
type: "panel",
cards: [boardCard],
},
...rosterEntries(hass).map<View>((entry) => ({
path: entry.slug,
title: entry.name
? `${entry.name}'s Quest Log`
: `${entry.slug}'s Quest Log`,
type: "panel",
cards: [{ ...logCard }],
})),
],
};
}
}
window.customStrategies = window.customStrategies ?? {};
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
-80
View File
@@ -1,80 +0,0 @@
/**
* Render the built party-board card under jsdom and print its plates.
*
* Usage: node render-party-board.mjs < spec.json
*
* The spec JSON carries {bundle, tag, config, hass, url?, click_plate?};
* stdout receives one JSON object {plates, tappable, location, html} where
* plates is the ordered plate list the shadow DOM renders (name +
* present/away/unknown kind), tappable the plate names rendered as tappable
* <button> plates, and location the window pathname after the optional plate
* tap. The Python panel tests drive this harness so the assertions run
* against the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/board",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the dispatched event to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement(spec.tag);
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
const plateNodes = [
...element.shadowRoot.querySelectorAll(".plates .plate"),
];
const plates = plateNodes.map((plate) => ({
name: plate.querySelector(".name")?.textContent?.trim() ?? "",
kind: plate.classList.contains("unknown")
? "unknown"
: plate.classList.contains("away")
? "away"
: "present",
}));
const tappable = plateNodes
.filter((plate) => plate.tagName === "BUTTON")
.map((plate) => plate.querySelector(".name")?.textContent?.trim() ?? "");
if (spec.click_plate !== undefined) {
const plate = plateNodes.find(
(candidate) =>
candidate.querySelector(".name")?.textContent?.trim() === spec.click_plate
);
plate?.click();
}
const payload = JSON.stringify({
plates,
tappable,
location: window.location.pathname,
html: element.shadowRoot.innerHTML,
});
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-59
View File
@@ -1,59 +0,0 @@
/**
* Render the built quest-log card under jsdom and report its location.
*
* Usage: node render-quest-log.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass, idle_ms?}; the card is
* mounted at the given URL (it resolves its child from the pathname's last
* segment), and after the optional idle wait stdout receives one JSON object
* {location, headline, html} — the window pathname and the rendered title.
* The Python panel tests drive this harness so the assertions run against
* the exact bundle HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/ada",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the card does not need them.
}
}
}
globalThis.window = dom.window;
// The card navigates by dispatching new Event("location-changed") on this
// window; jsdom's EventTarget rejects Node's own global Event class, so the
// jsdom Event must shadow it for the return navigation to be delivered.
globalThis.Event = dom.window.Event;
await import(pathToFileURL(spec.bundle).href);
const element = document.createElement("nestquest-quest-log-card");
element.setConfig(spec.config);
element.hass = spec.hass;
document.body.appendChild(element);
await element.updateComplete;
if (spec.idle_ms !== undefined) {
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
}
const payload = JSON.stringify({
location: window.location.pathname,
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
html: element.shadowRoot.innerHTML,
});
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
-48
View File
@@ -1,48 +0,0 @@
/**
* Import the built bundle into jsdom and generate the dashboard strategy's
* views.
*
* Usage: node render-strategy.mjs < spec.json
*
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
* JSON object {views} — the strategy output's views array with the paths
* and card configs the generated dashboard renders. The Python panel
* tests drive this harness so the assertions run against the exact bundle
* HACS ships.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { JSDOM } from "jsdom";
const spec = JSON.parse(readFileSync(0, "utf8"));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: spec.url ?? "http://homeassistant.local/nestquest/party",
pretendToBeVisual: true,
});
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (!(key in globalThis)) {
try {
globalThis[key] = dom.window[key];
} catch {
// jsdom getters may throw on access; the strategy does not need them.
}
}
}
globalThis.window = dom.window;
await import(pathToFileURL(spec.bundle).href);
const strategy = window.customStrategies?.["nestquest-party"];
if (!strategy) {
console.error("nestquest-party strategy is not registered on the bundle");
process.exit(1);
}
const lovelace = await strategy.generate(spec.config, spec.hass);
const payload = JSON.stringify({ views: lovelace.views });
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
process.exit(0);
+185 -76
View File
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
from __future__ import annotations
import asyncio
import datetime
import importlib.util
import inspect
import sys
@@ -741,92 +742,160 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
return entry
class StubSnapshotClient:
"""A no-network stand-in for the API client's snapshot surface.
class LazyLocalSnapshotClient:
"""A test client that serves the snapshot built LOCALLY.
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
payload dict or an exception per call, consumed in order — and
records every call. When the queue is exhausted it re-serves the
last outcome, so setup's first refresh and a test's explicit
refreshes poll the same scripted state. Register it (or any
scripted client) with :func:`set_coordinator_client` BEFORE setup.
Feature 18 moved the coordinator's refresh onto the API service's
panel snapshot, but most integration tests still seed the local
database directly (the DB removal is a later task). This stub
bridges the two: ``get_snapshot`` resolves the entry's runtime
record (its database and settings) and runs the SAME pure core
builder the API route runs
(:func:`~custom_components.nestquest.core.snapshot.build_snapshot`),
shaping the payload exactly like ``api/routes_panel.py`` —
instances via ``instance_payload(include_missed=False)`` — so the
coordinator reconstructs from a byte-identical payload without any
network or FastAPI machinery.
``complete_instance`` bridges the panel complete route the same
way (the ``nestquest.complete_quest`` proxy calls it through the
API client): it resolves the entry's database and delegates to the
SAME core completion the API route delegates to, with the panel
actor shape, and answers the route's ``{"status": ...}`` body.
Like the real API service it publishes its transition events on
the SSE stream — NOT on the Home Assistant bus and NOT from the
service handler; tests that assert bus events drive them through
the SSE subscription the way production delivers them.
The resolution is LAZY on purpose: the coordinator's factory runs
mid-setup, before the runtime record exists, so the first refresh
(and any refresh while the entry is torn down) serves an EMPTY but
valid snapshot; every later refresh reads the seeded database.
"""
def __init__(self, *outcomes) -> None:
def __init__(self, hass, entry_id: str) -> None:
self._hass = hass
self._entry_id = entry_id
self.calls = 0
self._outcomes = list(outcomes)
self._last = outcomes[-1] if outcomes else None
#: The SSE frames the stand-in "published" (the API service's
# stream content), drained by :func:`refire_api_transitions`.
#: Every ``(instance_id, CompletionResult)`` this stand-in
# produced, in call order — tests read ``appended`` /
# ``was_on_time`` off these to check the completion verdicts.
self.completions = []
#: The SSE frames the stand-in published for its completions
# (the API service's stream content), drained by
#: :func:`refire_api_transitions`.
self.published_frames = []
def _live_runtime(self):
"""The entry's runtime record, or ``None`` before/after setup."""
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
database = getattr(runtime, "database", None)
if database is None or not getattr(database, "connected", True):
return None
return runtime
def _live_database(self):
"""The entry's open database, or ``None`` before/after setup."""
runtime = self._live_runtime()
return None if runtime is None else runtime.database
async def get_snapshot(self):
import datetime
import zoneinfo
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
from custom_components.nestquest.core import snapshot as core_snapshot
self.calls += 1
if not self._outcomes:
# Re-serve the last outcome: setup's first refresh and the
# test's explicit pass both poll the same scripted state.
outcome = self._last
else:
outcome = self._outcomes.pop(0)
self._last = outcome
if isinstance(outcome, BaseException):
raise outcome
return outcome
runtime = self._live_runtime()
if runtime is None:
return {
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"children": [],
}
database = runtime.database
settings = getattr(runtime, "settings", None)
# The HA-LOCAL now, the same single clock read the production
# coordinator used to hand the builder: the hass stub's
# configured time zone, never the host clock.
now = datetime.datetime.now(
zoneinfo.ZoneInfo(self._hass.config.time_zone)
)
snapshot = await core_snapshot.build_snapshot(database, settings, now)
return {
"today_iso": snapshot.today_iso,
"cycle_day": snapshot.cycle_day,
"children": [
{
"child_id": child.child_id,
"child_name": child.child_name,
"present": child.present,
"next_present": child.next_present,
"due_today": child.due_today,
"completed_today": child.completed_today,
"remaining_today": child.remaining_today,
"completion_pct": child.completion_pct,
"instances": core_snapshot.instance_payload(
child.instances, include_missed=False
),
}
for child in snapshot.children
],
}
async def complete_instance(self, instance_id: int, actor_child_id: int):
"""The panel complete route's stand-in: complete through the
SAME core completion the route delegates to, panel actor
shape, publish the route's SSE transition frames (built by the
same core builders, appended completions only — see
``api/routes_panel.py``), and answer the route's
``{"status": ...}`` body."""
import datetime
class ScriptedPanelClient(StubSnapshotClient):
"""Snapshot + completion stand-in for the API service's panel plane.
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.core import events as core_events
Serves the scripted snapshot outcomes (like
:class:`StubSnapshotClient`), records ``complete_instance`` calls
the way the panel route receives them, optionally fails them the
way the client types the failure, and carries the SSE frames the
API "published" in ``published_frames`` for
:func:`refire_api_transitions` to deliver. Everything the
integration reads over the wire, no network.
"""
def __init__(self, *outcomes) -> None:
super().__init__(*outcomes)
self.complete_calls: list[tuple[int, int]] = []
self.published_frames: list[tuple[str, dict]] = []
#: When set, the next ``complete_instance`` raises this instead
# of succeeding (a typed NestQuestApiError in production).
self.fail_complete_with: BaseException | None = None
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict:
self.complete_calls.append((instance_id, actor_child_id))
if self.fail_complete_with is not None:
raise self.fail_complete_with
return {"status": "done"}
def set_coordinator_client(entry, client):
"""Register a scripted client for ``entry``'s coordinator factory."""
import custom_components.nestquest.coordinator as coordinator_module
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
return client
result = await complete_instance(
self._live_database(),
instance_id,
actor_source="panel",
actor_child_id=actor_child_id,
)
self.completions.append((instance_id, result))
if result.appended:
# The route publishes the transition the moment the core
# write lands (appended completions only), evaluating the
# day-complete rule against the API host's local date —
# the frames queue here for the SSE stream to carry.
event_type, payload = await core_events.build_quest_completed_event(
self._live_database(),
instance_id,
was_on_time=result.was_on_time,
)
self.published_frames.append((event_type, payload))
now = datetime.datetime.now().astimezone()
day_complete = await core_events.build_child_day_complete_event(
self._live_database(), payload, today=now.date()
)
if day_complete is not None:
self.published_frames.append(day_complete)
return {"status": str(result)}
@pytest.fixture(autouse=True)
def coordinator_client_from_test_override():
"""Resolve every entry's coordinator client through the test seam.
def coordinator_serves_local_snapshot():
"""Default every test entry's coordinator to the local-snapshot client.
The integration's setup builds its API client through
``coordinator_client_from_entry``; this fixture swaps that factory
for a test one handing out the client registered with
:func:`set_coordinator_client`, or — for an entry with no scripted
client — a stub serving an EMPTY but valid household snapshot (the
shape the API's panel route returns before any child exists; the
real client would need aiohttp, which this mock-only harness does
not provide).
``coordinator_client_from_entry``; the fixture swaps that factory
for a test one handing out :class:`LazyLocalSnapshotClient` (so
DB-seeded tests exercise the coordinator's API path against local
data). Tests that script their own client register it with
:func:`set_coordinator_client`, which the factory honours first.
"""
import datetime
import unittest.mock as mock
import custom_components.nestquest as nq_mod
import custom_components.nestquest.coordinator as coordinator_module
@@ -838,13 +907,9 @@ def coordinator_client_from_test_override():
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
if override is not None:
return override
return StubSnapshotClient(
{
"today_iso": datetime.date.today().isoformat(),
"cycle_day": 0,
"children": [],
}
)
return LazyLocalSnapshotClient(hass, entry.entry_id)
import unittest.mock as mock
_COORDINATOR_CLIENT_OVERRIDES.clear()
patcher = mock.patch.object(
@@ -856,6 +921,14 @@ def coordinator_client_from_test_override():
_COORDINATOR_CLIENT_OVERRIDES.clear()
def set_coordinator_client(entry, client):
"""Register a scripted client for ``entry``'s coordinator factory."""
import custom_components.nestquest.coordinator as coordinator_module
coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client
return client
async def refire_api_transitions(hass, entry, client):
"""Deliver the SSE frames the stand-in client has published so far
through the integration's SSE subscription — the production event
@@ -1132,6 +1205,42 @@ def make_config_flow_entry():
return _factory
def freeze_nestquest_clock(
monkeypatch, instant: datetime.datetime
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
so the generation path's ``datetime.datetime.now`` reads this instant
(localized to whatever time zone it asks for) while every other module
and the test harness keep the real clock. Returns ``(frozen, requested)``
where ``frozen`` is the instant normalized to UTC and ``requested`` is
the list of ``tzinfo`` objects the code under test passed to ``now`` —
so a caller can prove the generation path actually requested the
configured HA time zone rather than silently reading UTC.
"""
import custom_components.nestquest as nestquest
frozen = instant.astimezone(datetime.timezone.utc)
requested: list = []
class _FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
requested.append(tz)
if tz is None:
return frozen.replace(tzinfo=None)
return frozen.astimezone(tz)
fake_module = SimpleNamespace(
datetime=_FrozenDatetime,
timedelta=datetime.timedelta,
date=datetime.date,
)
monkeypatch.setattr(nestquest, "datetime", fake_module)
return frozen, requested
# ---------------------------------------------------------------------------
# Admin-plane API fixtures (shared by the admin-plane test modules).
#
+210 -7
View File
@@ -1,18 +1,21 @@
"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist)."""
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest.core.admin_allowlist import (
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
seed_setup_admin,
set_admin_ids,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.dao_children import AdminUsersDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
@@ -190,6 +193,165 @@ def test_seed_rejects_malformed_candidates(tmp_path) -> None:
# ---------------------------------------------------------------------------
# Setup wiring: async_setup_entry seeds automatically
# ---------------------------------------------------------------------------
async def _setup_entry(hass, entry, registry):
from tests.test_lifecycle import _wire
entry = _wire(entry, registry)
await async_setup_entry(hass, entry)
return entry
async def test_setup_seeds_allowlist_from_entry_context(
hass, make_entry
) -> None:
entry = await _setup_entry(
hass,
make_entry(context={"user_id": "ha-owner"}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["ha-owner"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_seeds_allowlist_from_stored_entry_data(
hass, make_entry
) -> None:
"""A lost database re-seeded from the entry's stored admin copy."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass,
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["user-a", "user-b"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_without_context_leaves_allowlist_empty(
hass, make_entry
) -> None:
"""Fail closed: no context user and no stored copy means an empty
allowlist — nobody is admin until the owner configures it."""
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == []
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_does_not_reseed_narrowed_allowlist(
hass, make_entry
) -> None:
from custom_components.nestquest import async_unload_entry
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database = entry.runtime_data.database
await set_admin_ids(database, ["user-parent"])
await database.close()
assert await async_unload_entry(hass, entry) is True
# Reload with the SAME context: the narrowed list survives.
entry_reloaded = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database_b = entry_reloaded.runtime_data.database
try:
assert await list_admin_ids(database_b) == ["user-parent"]
finally:
await database.close()
assert await async_unload_entry(hass, entry_reloaded) is True
async def test_setup_seed_prefers_options_over_entry_data(
hass, make_entry
) -> None:
"""Seed precedence on a fresh database: the options flow's saved
list (the current one) beats the config flow's original copy."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass,
make_entry(
options={CONF_ADMIN_USER_IDS: ["user-current"]},
data={CONF_ADMIN_USER_IDS: ["ha-owner"]},
context={"user_id": "ha-owner"},
),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["user-current"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_owner_fallback_seeds_owner_accounts(
hass, make_entry
) -> None:
"""Last-resort seed: with no persisted copy and no flow-context
user, HA owner accounts are seeded so first setup is never
ownerless (the real flow may lose the context; owners can already
do everything in HA)."""
from unittest.mock import AsyncMock
hass.auth.async_get_users = AsyncMock(
return_value=[
SimpleNamespace(id="owner-1", is_owner=True),
SimpleNamespace(id="member-1", is_owner=False),
SimpleNamespace(id="owner-2", is_owner=True),
]
)
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_owner_fallback_not_used_when_context_present(
hass, make_entry
) -> None:
"""The context user (flow caller) wins over the owner fallback:
a non-owner parent configuring the integration becomes the admin."""
from unittest.mock import AsyncMock
hass.auth.async_get_users = AsyncMock(
return_value=[SimpleNamespace(id="owner-1", is_owner=True)]
)
entry = await _setup_entry(
hass,
make_entry(context={"user_id": "parent-configured"}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["parent-configured"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
def test_seed_owner_ids_argument(tmp_path) -> None:
async def _body(database):
# Malformed owner lists are rejected like any other candidate —
@@ -215,13 +377,54 @@ def test_seed_owner_ids_argument(tmp_path) -> None:
_with_db(tmp_path, "seed-owner-arg.db")(_body)
async def test_reopened_setup_failure_closes_database(
hass, make_entry, monkeypatch
) -> None:
"""A seeding failure on the reopened-record path closes the just-
opened connection before re-raising (no leaked handles across HA's
setup retries)."""
import pytest as pytest_module
from unittest.mock import AsyncMock
import custom_components.nestquest as nestquest_module
from custom_components.nestquest.db import NestQuestDatabase
# First: a successful setup to create the runtime record and file.
entry = await _setup_entry(hass, make_entry(), hass.registry)
await async_unload_entry(hass, entry)
# Now a re-setup whose seeding fails after the reopen.
async def _failing_seed(database, **kwargs):
raise RuntimeError("seed boom")
monkeypatch.setattr(
nestquest_module, "seed_setup_admin", _failing_seed, raising=True
)
close_calls: list[bool] = []
original_close = NestQuestDatabase.close
async def _spy_close(self):
close_calls.append(True)
return await original_close(self)
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
with pytest_module.raises(RuntimeError, match="seed boom"):
await _setup_entry(hass, entry, hass.registry)
# The reopen path opened AND closed a connection; the failure is
# the last thing observed.
assert close_calls[-1] is True
# ---------------------------------------------------------------------------
# is_admin: the fail-closed resolver (Feature 09's verdict function)
# ---------------------------------------------------------------------------
def test_is_admin_verdict_matrix(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.core.admin_allowlist import is_admin
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
assert await is_admin(database, "user-owner") is True
@@ -244,7 +447,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None:
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
"""An empty allowlist returns False for EVERYONE, not True."""
async def _body(database):
from custom_components.nestquest.core.admin_allowlist import is_admin
from custom_components.nestquest.admin_allowlist import is_admin
for candidate in ("user-owner", "", None, 1):
assert await is_admin(database, candidate) is False
@@ -257,7 +460,7 @@ def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
"""The refusal keeps the verdict sound: a refused removal leaves
the admin in place, so is_admin still resolves True."""
async def _body(database):
from custom_components.nestquest.core.admin_allowlist import is_admin
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
with pytest.raises(ValueError, match="at least one admin"):
+123 -134
View File
@@ -1,85 +1,50 @@
"""Tests for the per-child binary sensors (Feature 10, task 4).
The snapshot comes from the API service's panel route (the presence
engine resolves server-side), so these tests script the route's
payload through :class:`conftest.StubSnapshotClient` instead of
seeding a local database.
"""
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
from __future__ import annotations
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child, list_children
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.completion import complete_instance
SEED_HORIZON_DAYS = 3
def _binary(hass, child_id: int, kind: str):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
def _child(
child_id: int,
name: str,
*,
present=True,
next_present=None,
instances=(),
):
completed = sum(1 for i in instances if i["state"] == "completed")
due = len(instances)
return {
"child_id": child_id,
"child_name": name,
"present": present,
"next_present": next_present,
"due_today": due,
"completed_today": completed,
"remaining_today": due - completed,
"completion_pct": 100 if due == 0 else round(completed / due * 100),
"instances": list(instances),
}
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
return {
"id": instance_id,
"definition_id": 3,
"child_id": child_id,
"title": "Brush teeth",
"icon": "🦷",
"window": "morning",
"due_time": "08:00",
"state": state,
"overdue": False,
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
"on_time": None if state == "open" else True,
}
def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"children": list(children),
}
ADA = 1
BO = 2
CORY = 3
async def _setup_entry(hass, make_entry, *payloads):
"""Set up the integration with a scripted snapshot client."""
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
),
hass.registry,
)
set_coordinator_client(entry, StubSnapshotClient(*payloads))
async def _setup_entry(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry, entry.runtime_data.coordinator
return entry
async def _seed_two_children(database) -> tuple:
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return ada, bo
async def test_all_done_off_with_open_quests_and_zero_quests(
@@ -87,76 +52,88 @@ async def test_all_done_off_with_open_quests_and_zero_quests(
) -> None:
"""all_done is OFF while quests are owed AND on a zero-quest day;
a child with no quests never fires the celebration automation."""
_entry, coordinator = await _setup_entry(
hass,
make_entry,
_payload(
[
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
_child(BO, "Bo", instances=[_instance(8, BO)]),
_child(CORY, "Cory"),
]
),
)
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory") # zero quests
await coordinator.async_refresh()
assert _binary(hass, ADA, "all_done").is_on is False
assert _binary(hass, BO, "all_done").is_on is False
assert _binary(hass, CORY, "all_done").is_on is False
assert _binary(hass, ada.id, "all_done").is_on is False
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "all_done").is_on is False
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
_entry, coordinator = await _setup_entry(
hass,
make_entry,
_payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]),
_payload(
[
_child(
ADA,
"Ada",
instances=[_instance(7, ADA, state="completed")],
)
]
),
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
snapshot_ada = [
child for child in coordinator.data.children
if child.child_id == ada.id
][0]
instance_id = snapshot_ada.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert _binary(hass, ADA, "all_done").is_on is True
assert _binary(hass, ada.id, "all_done").is_on is True
async def test_present_today_on_without_schedule_off_when_absent(
hass, make_entry
) -> None:
"""Presence is resolved by the API's presence engine: present=True
renders ON, present=False renders OFF."""
_entry, _coordinator = await _setup_entry(
hass,
make_entry,
_payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]),
"""No schedule means present every day; an all-absent pattern
today reports OFF."""
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
await coordinator.async_refresh()
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
await PresenceSchedulesDao(database).upsert_by_child(
cory.child_id, 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ADA, "present_today").is_on is True
assert _binary(hass, CORY, "present_today").is_on is False
assert _binary(hass, ada.id, "present_today").is_on is True
assert _binary(hass, cory.child_id, "present_today").is_on is False
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
_entry, _coordinator = await _setup_entry(
hass, make_entry, _payload([_child(ADA, "Ada")])
)
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await coordinator.async_refresh()
all_done = _binary(hass, ADA, "all_done")
present = _binary(hass, ADA, "present_today")
assert all_done.unique_id == f"nestquest_child_{ADA}_all_done"
assert present.unique_id == f"nestquest_child_{ADA}_present_today"
all_done = _binary(hass, ada.id, "all_done")
present = _binary(hass, ada.id, "present_today")
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
assert all_done.name == "NestQuest Ada all done"
assert present.name == "NestQuest Ada present today"
for entity in (all_done, present):
assert entity.device_info.name == "NestQuest Ada"
assert all_done.extra_state_attributes == {
"child_id": ADA,
"child_id": ada.id,
"child_name": "Ada",
}
assert present.extra_state_attributes == {
"child_id": ADA,
"child_id": ada.id,
"child_name": "Ada",
"next_present": None,
}
@@ -168,28 +145,40 @@ async def test_present_today_carries_next_present_when_away(
"""A child away today carries the ISO date of their next present
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
from it); an always-absent pattern carries ``None``."""
_entry, _coordinator = await _setup_entry(
hass,
make_entry,
_payload(
[
_child(
ADA,
"Ada",
present=False,
next_present="2026-09-24",
),
_child(CORY, "Cory", present=False, next_present=None),
]
),
entry = await _setup_entry(hass, make_entry)
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
ada, _bo = await _seed_two_children(database)
await create_child(database, "Cory")
today = datetime.date.today()
pattern = ",".join(
str(day) for day in range(7) if day != today.weekday()
)
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 1, today.isoformat(), pattern
)
await PresenceSchedulesDao(database).upsert_by_child(
await _cory_id(database), 1, today.isoformat(), ""
)
await coordinator.async_refresh()
assert _binary(hass, ADA, "present_today").is_on is False
assert _binary(hass, ADA, "present_today").extra_state_attributes[
assert _binary(hass, ada.id, "present_today").is_on is False
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
"next_present"
] == "2026-09-24"
] == (today + datetime.timedelta(days=1)).isoformat()
assert _binary(hass, CORY, "present_today").is_on is False
assert _binary(hass, CORY, "present_today").extra_state_attributes[
cory = [
child for child in coordinator.data.children
if child.child_name == "Cory"
][0]
assert _binary(hass, cory.child_id, "present_today").is_on is False
assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[
"next_present"
] is None
async def _cory_id(database) -> int:
for child in await list_children(database):
if child.display_name == "Cory":
return child.id
raise AssertionError("Cory was not created")
+8 -8
View File
@@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest.core.children import (
from custom_components.nestquest.children import (
create_child,
edit_child,
list_children,
reorder_children,
set_child_active,
)
from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
@@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events(
async def _body(database):
from datetime import date, timedelta
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
ScheduleRulesDao,
)
@@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None:
import inspect
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.dao_children import ChildrenDao
methods = {
name
+116 -47
View File
@@ -5,69 +5,117 @@ complete route through the entry's ONE shared API client (the same
instance the coordinator polls and the SSE subscription reads), with
exactly the payload contract the panel cards send (``instance_id``,
``actor=panel``, ``actor_child_id``). The service performs NO local
completion and fires NO local bus event — the API service emits the
transition on its SSE stream and the integration's subscription
re-fires it (firing it locally too would double-fire) — and it
refreshes the coordinator after a successful call so the sensors
reflect the completion immediately. Every failure (a typed
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
surfaces as a :class:`HomeAssistantError`, token-free.
database completion and fires NO local bus event — the API service
emits the transition on its SSE stream and the integration's
subscription re-fires it (firing it locally too would double-fire) —
and it refreshes the coordinator after a successful call so the
sensors reflect the completion immediately. Every failure (a typed
:class:`NestQuestApiError`, an unconfigured client, a non-panel
actor) surfaces as a :class:`HomeAssistantError`, token-free.
The scripted clients here stand in for the API service: recording and
failing ``complete_instance`` implementations, no network, no aiohttp.
"""
from __future__ import annotations
import datetime
import pytest
from homeassistant.exceptions import HomeAssistantError
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
from conftest import (
LazyLocalSnapshotClient,
set_coordinator_client,
wire_entry_to_registry,
)
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.api_client import NestQuestApiError
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_PANEL_TOKEN,
DOMAIN,
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
ADMIN_CTX = {"user_id": "admin-1"}
class _RecordingApiClient(ScriptedPanelClient):
class _RecordingApiClient(LazyLocalSnapshotClient):
"""Snapshot-only stand-in that RECORDS completion calls.
``complete_instance`` deliberately performs NO local write: the
``complete_instance`` deliberately performs NO database write: the
API service is remote, and the assertions below must be able to
prove the service handler itself completed nothing locally.
"""
def __init__(self, hass, entry_id: str) -> None:
super().__init__(hass, entry_id)
self.complete_calls: list[tuple[int, int]] = []
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict:
self.complete_calls.append((instance_id, actor_child_id))
return {"status": "done"}
class _FailingApiClient(_RecordingApiClient):
"""A stand-in whose completion fails the way the client types it."""
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
super().__init__(*outcomes)
self.fail_complete_with = error
def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None:
super().__init__(hass, entry_id)
self._error = error
async def complete_instance(
self, instance_id: int, actor_child_id: int
) -> dict:
self.complete_calls.append((instance_id, actor_child_id))
raise self._error
def _empty_snapshot() -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"children": [],
}
async def _setup_entry(hass, make_entry, client):
"""Wire, script, and set up an entry against the scripted client."""
async def _setup_seeded_entry(hass, make_entry, client):
"""Wire, script, and set up an entry with one child's daily quest."""
entry = wire_entry_to_registry(
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
make_entry(
data={
CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]],
CONF_PANEL_TOKEN: "secret-panel-token",
}
),
hass.registry,
)
set_coordinator_client(entry, client)
assert await async_setup_entry(hass, entry) is True
return entry
database = entry.runtime_data.database
from custom_components.nestquest.children import create_child
child = await create_child(database, "Ada")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[child.id],
["morning"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today.isoformat(), today.isoformat()
)
assert instances
return entry, client, child, instances[0]
def _record_refreshes(entry) -> list[bool]:
@@ -85,10 +133,11 @@ async def test_complete_quest_reaches_the_shared_api_client(
hass, make_entry
) -> None:
"""A panel tap calls ``complete_instance`` on the entry's shared
client with the tapped child, fires NO local event, and refreshes
the coordinator immediately."""
client = _RecordingApiClient(_empty_snapshot())
entry = await _setup_entry(hass, make_entry, client)
client with the tapped child, completes NOTHING locally, fires NO
local event, and refreshes the coordinator immediately."""
entry, client, child, instance = await _setup_seeded_entry(
hass, make_entry, _RecordingApiClient(hass, "test_entry")
)
assert entry.runtime_data.coordinator.api_client is client, (
"the service must proxy through the entry's ONE shared client"
)
@@ -98,13 +147,21 @@ async def test_complete_quest_reaches_the_shared_api_client(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": 7,
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": 1,
"actor_child_id": child.id,
},
)
assert client.complete_calls == [(7, 1)]
assert client.complete_calls == [(instance.id, child.id)]
# No local completion: the instance is still open in the local
# database — only the API service writes (through its own client).
from custom_components.nestquest.completion import instance_state
assert (
await instance_state(entry.runtime_data.database, instance.id)
== "open"
)
# No local firing: the completed (and day-complete) events arrive
# over the SSE stream, re-fired by the subscription — never from
# this handler.
@@ -141,10 +198,13 @@ async def test_api_failure_surfaces_as_a_service_error(
) -> None:
"""A typed API failure (non-2xx or transport) raises
``HomeAssistantError`` carrying the typed message — which never
includes the panel token — with nothing fired locally, and no
refresh for a mutation that never happened."""
client = _FailingApiClient(error, _empty_snapshot())
entry = await _setup_entry(hass, make_entry, client)
includes the panel token — with nothing completed or fired
locally, and no refresh for a mutation that never happened."""
entry, client, child, instance = await _setup_seeded_entry(
hass,
make_entry,
_FailingApiClient(hass, "test_entry", error),
)
refreshes = _record_refreshes(entry)
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
@@ -152,16 +212,22 @@ async def test_api_failure_surfaces_as_a_service_error(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": 7,
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": 1,
"actor_child_id": child.id,
},
)
# The typed error is chained, and the service error carries the
# typed message only — never the panel token.
assert excinfo.value.__cause__ is error
assert "secret-panel-token" not in str(excinfo.value)
assert client.complete_calls == [(7, 1)]
assert client.complete_calls == [(instance.id, child.id)]
from custom_components.nestquest.completion import instance_state
assert (
await instance_state(entry.runtime_data.database, instance.id)
== "open"
)
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
assert refreshes == []
@@ -175,7 +241,9 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error(
of reaching an attribute error."""
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
entry, _client, child, instance = await _setup_seeded_entry(
hass, make_entry, _UnconfiguredApiClient()
)
with pytest.raises(
HomeAssistantError, match="NestQuest API is not configured"
@@ -184,9 +252,9 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": 7,
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": 1,
"actor_child_id": child.id,
},
)
@@ -197,18 +265,19 @@ async def test_non_panel_actor_is_rejected_before_the_client(
"""The API's panel route completes as the tapped child only: a
user-actor call fails fast with a clear error and never reaches
the client."""
client = _RecordingApiClient(_empty_snapshot())
await _setup_entry(hass, make_entry, client)
entry, client, child, instance = await _setup_seeded_entry(
hass, make_entry, _RecordingApiClient(hass, "test_entry")
)
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": 7,
"instance_id": instance.id,
"actor": "user",
"actor_user_id": "admin-1",
"actor_user_id": ADMIN_CTX["user_id"],
},
context={"user_id": "admin-1"},
context=ADMIN_CTX,
)
assert client.complete_calls == []
+7 -7
View File
@@ -8,27 +8,27 @@ import re
import pytest
from custom_components.nestquest.core.completion import (
from custom_components.nestquest.completion import (
append_event,
complete_instance,
instance_state,
list_missed_for_child,
uncomplete_instance,
)
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import (
EVENT_COMPLETED,
EVENT_UNCOMPLETED,
CompletionEventRecord,
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
ScheduleRulesDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
@@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None:
def test_module_never_exposes_update_or_delete() -> None:
import custom_components.nestquest.core.completion as completion
import custom_components.nestquest.completion as completion
public = {
name
+15 -5
View File
@@ -156,13 +156,23 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
)
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
"""The HA user completing the flow lands in entry data as the
initial admin copy: entry.context is not persisted by real HA, so
the durable record must carry the owner."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
flow = make_flow(hass)
flow.context = {"user_id": "ha-owner"}
result = await flow.async_step_user({})
assert result["type"] == "create_entry"
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
async def test_flow_without_context_user_keeps_data_untouched(
hass, make_flow
) -> None:
"""A submit with user input stores it as-is; no admin copy is
invented (the allowlist lives in the API service's own store since
the DB removal)."""
flow = make_flow(hass)
result = await flow.async_step_user({"update_interval": 60})
result = await flow.async_step_user({"horizon_days": 7})
assert result["type"] == "create_entry"
assert result["data"] == {"update_interval": 60}
assert result["data"] == {"horizon_days": 7}
+87 -4
View File
@@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes:
test feeds the scanner each bypass shape and asserts it flags them.
- A settings object constructed with NON-default ``horizon_days`` and
``day_rollover_time`` drives a materialization whose resulting window
matches the non-default horizon — via the core ``materialize`` path
(the integration path it used to share is gone with the integration's
database).
matches the non-default horizon — via BOTH the core ``materialize``
path and the INTEGRATION path (``_run_horizon_materialization`` +
``_register_day_rollover_listener``).
"""
from __future__ import annotations
@@ -29,6 +29,7 @@ from typing import Any
import pytest
import custom_components.nestquest.core as _core_mod
from conftest import freeze_nestquest_clock
from custom_components.nestquest.const import (
CONF_AFTERNOON_REMINDER_ENABLED,
@@ -740,7 +741,7 @@ def test_settings_module_has_no_ha_imports() -> None:
# ---------------------------------------------------------------------------
# Materialization over a non-default horizon (core path).
# Materialization over a non-default horizon: core path + integration path.
# ---------------------------------------------------------------------------
@@ -827,3 +828,85 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
) == []
finally:
await database.close()
async def test_integration_path_threads_settings(
hass, tmp_path, monkeypatch
) -> None:
"""The integration path threads ``settings`` through horizon_window + rollover.
``_run_horizon_materialization`` calls
``settings.horizon_window(today)`` (so the helper is real and shared,
not recomputed inline), and ``_register_day_rollover_listener``
registers at ``settings.day_rollover_hour_minute``. This runs the
INTEGRATION path with ``horizon_days=3`` and
``day_rollover_time='03:30'`` and asserts the resulting instance
window matches [today, today+3] AND the registered hour/minute are 3/30.
"""
import custom_components.nestquest as nestquest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
QuestInstancesDao,
)
from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.core.recurrence import (
RuleType,
ScheduleRule,
)
# Freeze the integration clock so "today" is deterministic (the
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
# localizes to 2026-03-01).
freeze_nestquest_clock(
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
)
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
try:
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
today = datetime.date(2026, 3, 1)
NOW = "2026-03-01T12:00:00+00:00"
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(
rule_type=RuleType.DAILY, start_date=today.isoformat()
),
[child.id],
["morning"],
)
# Integration path: the window comes from settings.horizon_window.
await nestquest._run_horizon_materialization(hass, database, settings)
end = today + datetime.timedelta(days=3)
records = await QuestInstancesDao(database).list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
dates = sorted({r.due_date for r in records})
assert dates == [
(today + datetime.timedelta(days=i)).isoformat()
for i in range(4)
]
beyond = (end + datetime.timedelta(days=1)).isoformat()
far = (end + datetime.timedelta(days=10)).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child.id, beyond, far
) == []
# The rollover listener registers at the configured hour/minute.
remove = nestquest._register_day_rollover_listener(
hass, database, settings
)
try:
reg = hass.time_change.registrations[-1]
assert reg["hour"] == 3
assert reg["minute"] == 30
finally:
remove()
finally:
await database.close()
+17 -30
View File
@@ -10,22 +10,17 @@ child's ``next_present``, a due-timed quest's ``overdue`` under a
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
``missed`` omission) are all exercised as literals.
The database is a standalone core one (the integration owns no
database since Feature 18's DB removal); the closing round-trip pins
that the integration's coordinator reconstructs the SAME dataclasses
from the route's payload.
"""
from __future__ import annotations
import datetime
from zoneinfo import ZoneInfo
from conftest import set_coordinator_client, wire_entry_to_registry
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.core.children import create_child
from custom_components.nestquest.core.completion import complete_instance
from custom_components.nestquest.children import create_child
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.core.snapshot import (
ChildDaySnapshot,
NestQuestSnapshot,
@@ -33,17 +28,14 @@ from custom_components.nestquest.core.snapshot import (
build_snapshot,
instance_payload,
)
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.materialize import materialize
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import create_quest_definition
from custom_components.nestquest.core.recurrence import ScheduleRule
from custom_components.nestquest.core.settings import NestQuestSettings
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import create_quest_definition
from custom_components.nestquest.recurrence import ScheduleRule
async def test_instance_payload_shape_and_missed_omission() -> None:
@@ -113,7 +105,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
)
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
"""The builder produces the documented shape as literals, pinned in time.
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
@@ -125,12 +117,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
scheduled active child is Ada, whose 2-week cycle anchored one day
before today puts ``cycle_day`` at 2.
"""
# A standalone core database (the integration owns none): the
# builder and its seed run against it through the executor.
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "snapshot.db")
await apply_migrations(database)
settings = NestQuestSettings()
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
settings = entry.runtime_data.settings
# Single clock read for the seed/snapshot anchor (no midnight skew);
# the coordinator no longer owns a clock read (the API serves the
@@ -312,6 +303,8 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# The dataclasses are frozen, so == is a full deep compare — a
# reconstruction that dropped or reshaped a field would diverge
# here even though the literal assertions above still pass.
from conftest import set_coordinator_client
class _PinnedPayloadClient:
async def get_snapshot(self):
return {
@@ -335,12 +328,6 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
],
}
entry = wire_entry_to_registry(
make_entry(data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}),
hass.registry,
)
set_coordinator_client(entry, _PinnedPayloadClient())
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
await coordinator.async_refresh()
assert coordinator.data == built
await database.close()
+33 -4
View File
@@ -6,15 +6,15 @@ import sqlite3
import pytest
from custom_components.nestquest.core.dao_children import (
from custom_components.nestquest.dao_children import (
AdminUserRecord,
AdminUsersDao,
ChildRecord,
ChildrenDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
@@ -504,6 +504,9 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
"tests/test_dao_rules.py", # rules/definitions guard, own scope
"tests/test_dao_presence.py", # presence guard, own scope
"tests/test_dao_instances.py", # instances guard, own scope
"tests/test_startup_db.py", # row-count probe of the children
# table only (proving a fresh setup loads); guard below covers
# everything else
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
@@ -555,6 +558,32 @@ def _strip_function_spans(text: str, names: set[str]) -> str:
for number, line in enumerate(lines, start=1)
if number not in excluded
)
def test_startup_db_test_file_uses_dao_elsewhere() -> None:
"""Compensating self-scan for the startup-test exemption: outside
the single sanctioned COUNT(*) probe, test_startup_db.py must go
through the DAO for children/admin_users data access.
"""
import re
from pathlib import Path
startup_test = Path(__file__).parent / "test_startup_db.py"
stripped = _strip_function_spans(
startup_test.read_text(),
{"test_setup_with_valid_file_preserves_rows"},
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b",
re.IGNORECASE,
)
assert sql_pattern.search(stripped) is None, (
"test_startup_db.py must go through the DAO for these tables "
"outside its sanctioned row-count probe"
)
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
"""Compensating self-scan for the matrix-file exemption, structurally
exact: parse the two sanctioned functions' ASTs and require that
+5 -5
View File
@@ -7,16 +7,16 @@ import sqlite3
import pytest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import (
CompletionEventRecord,
CompletionEventsDao,
QuestInstanceRecord,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
+6 -6
View File
@@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest.core.dao_children import (
from custom_components.nestquest.dao_children import (
AdminUsersDao,
ChildrenDao,
)
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
)
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_rules import (
ScheduleRulesDao,
QuestDefinitionsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
+8 -8
View File
@@ -6,15 +6,15 @@ import sqlite3
import pytest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceScheduleRecord,
PresenceSchedulesDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
@@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
from unittest.mock import MagicMock
async def _main():
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
armed = {"active": False, "gated": False}
gate_open = asyncio_module.Event()
+6 -6
View File
@@ -6,8 +6,8 @@ import sqlite3
import pytest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRuleStorage,
ScheduleRulesDao,
@@ -17,9 +17,9 @@ from custom_components.nestquest.core.dao_rules import (
schedule_rule_from_storage,
schedule_rule_to_storage,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
@@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# delete task has been scheduled, so the create holds
# the connection lock while the delete queues behind
# it — then the create proceeds and wins.
import custom_components.nestquest.core.dao_rules as dao_rules
import custom_components.nestquest.dao_rules as dao_rules
original_validate = (
dao_rules.QuestDefinitionsDao._validate_rule_exists
+66 -1
View File
@@ -14,7 +14,7 @@ import pytest
from conftest import executor_for, make_hass
import custom_components.nestquest.core.db as db_module
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.db import NestQuestDatabase
def _run(coro):
@@ -933,3 +933,68 @@ def test_execute_many_exposes_rowcount(tmp_path) -> None:
# ---------------------------------------------------------------------------
# Lifecycle wiring: opened once per entry, closed on unload
# ---------------------------------------------------------------------------
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
runtime = entry.runtime_data
assert runtime.database is not None
assert runtime.database.connected is True
await async_unload_entry(hass, entry)
async def test_unload_entry_closes_database(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
assert database.connected is True
assert await async_unload_entry(hass, entry) is True
assert database.connected is False
with pytest.raises(RuntimeError, match="closed"):
await database.fetch_one("SELECT 1")
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
first = entry.runtime_data.database
await async_setup_entry(hass, entry)
second = entry.runtime_data.database
assert first is second
assert first.connected is True
await async_unload_entry(hass, entry)
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
seen: list[NestQuestDatabase] = []
for _ in range(3):
await async_setup_entry(hass, entry)
runtime = entry.runtime_data
assert runtime.database.connected is True
seen.append(runtime.database)
await async_unload_entry(hass, entry)
assert runtime.database.connected is False
assert len(set(seen)) == 3
assert hass.data == {}
+107 -122
View File
@@ -1,16 +1,24 @@
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).
The household state the entities mirror comes from the API service's
panel route, so a deactivate/reactivate/add is scripted as a payload
change served by :class:`conftest.StubSnapshotClient`; the reload
(unload + setup) re-polls it, exactly as production does.
"""
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
from __future__ import annotations
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
import datetime
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
SEED_HORIZON_DAYS = 3
PER_CHILD_KINDS = (
"quests_due_today",
"quests_completed_today",
@@ -26,78 +34,6 @@ HOUSEHOLD_UNIQUE_IDS = (
"nestquest_cycle_day",
)
ADA = 1
BO = 2
CORY_ID = 4
def _child(child_id: int, name: str, *, present=True, instances=()):
completed = sum(1 for i in instances if i["state"] == "completed")
due = len(instances)
return {
"child_id": child_id,
"child_name": name,
"present": present,
"next_present": None,
"due_today": due,
"completed_today": completed,
"remaining_today": due - completed,
"completion_pct": 100 if due == 0 else round(completed / due * 100),
"instances": list(instances),
}
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
return {
"id": instance_id,
"definition_id": 3,
"child_id": child_id,
"title": "Brush teeth",
"icon": "🦷",
"window": "morning",
"due_time": "08:00",
"state": state,
"overdue": False,
"completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00",
"on_time": None if state == "open" else True,
}
def _payload(children) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": 0,
"children": list(children),
}
#: The full household (Ada + Bo, one daily quest each) and the states
#: the lifecycle walks through: Bo deactivated, reactivated, completed.
_BOTH = _payload(
[
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
_child(BO, "Bo", instances=[_instance(8, BO)]),
]
)
_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
_BO_DONE = _payload(
[
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
_child(BO, "Bo", instances=[_instance(8, BO, state="completed")]),
]
)
_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])])
_ADA_AND_CORY = _payload(
[
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
_child(CORY_ID, "Cory"),
]
)
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _reload(hass, entry) -> None:
"""Unload then set up: the harness's restart/reload equivalent."""
@@ -105,36 +41,46 @@ async def _reload(hass, entry) -> None:
assert await async_setup_entry(hass, entry) is True
async def _setup_with_household(hass, make_entry, *payloads):
"""Set up the entry with a scripted, ordered snapshot history."""
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
),
hass.registry,
)
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
def _child_unique_ids(child_id: int) -> set[str]:
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
async def _setup_and_seed(hass, make_entry):
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry, entry.runtime_data.coordinator, client
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, ada, bo
async def test_deactivated_child_entities_removed_on_reload(
hass, make_entry
) -> None:
"""A child absent from the API's snapshot loses its entities on
reload; the surviving child's entities are untouched."""
entry, _coordinator, _client = await _setup_with_household(
hass, make_entry, _BOTH, _BO_GONE
)
assert _child_unique_ids(ADA) <= set(hass.entities)
assert _child_unique_ids(BO) <= set(hass.entities)
entry, ada, bo = await _setup_and_seed(hass, make_entry)
assert _child_unique_ids(ada.id) <= set(hass.entities)
assert _child_unique_ids(bo.id) <= set(hass.entities)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert _child_unique_ids(ADA) <= set(hass.entities), (
assert _child_unique_ids(ada.id) <= set(hass.entities), (
"the surviving child's entities must be untouched"
)
assert not (_child_unique_ids(BO) & set(hass.entities)), (
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
"the deactivated child's entities must be gone after reload"
)
@@ -142,61 +88,100 @@ async def test_deactivated_child_entities_removed_on_reload(
async def test_reactivation_restores_entities_with_original_unique_ids(
hass, make_entry
) -> None:
original = _child_unique_ids(BO)
entry, _coordinator, _client = await _setup_with_household(
hass, make_entry, _BOTH, _BO_GONE, _BOTH
)
entry, ada, bo = await _setup_and_seed(hass, make_entry)
original = _child_unique_ids(bo.id)
await set_child_active(entry.runtime_data.database, bo.id, False)
await _reload(hass, entry)
assert not (original & set(hass.entities))
await set_child_active(entry.runtime_data.database, bo.id, True)
await _reload(hass, entry)
assert original <= set(hass.entities), (
"reactivation must restore the exact original unique_ids"
)
bo_due = hass.entities[f"nestquest_child_{BO}_quests_due_today"]
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
assert bo_due.native_value == 1, "restored entities carry live state"
async def test_new_child_gets_entities_without_disturbing_existing(
hass, make_entry
) -> None:
"""A child ADDED to the API's snapshot gains its entities on
reload; the existing children's entities are untouched."""
entry, _coordinator, _client = await _setup_with_household(
hass, make_entry, _ADA_ONLY, _ADA_AND_CORY
)
before_ada = set(hass.entities) & _child_unique_ids(ADA)
entry, ada, bo = await _setup_and_seed(hass, make_entry)
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
cory = await create_child(entry.runtime_data.database, "Cory")
await _reload(hass, entry)
assert before_ada <= set(hass.entities), "existing entities untouched"
assert _child_unique_ids(CORY_ID) <= set(hass.entities), (
assert _child_unique_ids(cory.id) <= set(hass.entities), (
"the new child's entities appear after reload"
)
assert hass.entities[
f"nestquest_child_{CORY_ID}_quests_due_today"
].native_value == 0
assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0
async def test_restart_equivalent_restores_all_entity_states(
hass, make_entry
) -> None:
"""Unload + setup (the restart equivalent) restores every active
child's entities with the API's current states and the household
rollups."""
entry, _coordinator, _client = await _setup_with_household(
hass, make_entry, _BOTH, _BO_DONE
child's entities with correct states and the household rollups."""
from custom_components.nestquest.completion import complete_instance
entry, ada, bo = await _setup_and_seed(hass, make_entry)
coordinator = entry.runtime_data.coordinator
snapshot_ada = next(
child
for child in coordinator.data.children
if child.child_id == ada.id
)
await complete_instance(
entry.runtime_data.database,
snapshot_ada.instances[0].instance_id,
actor_source="panel",
actor_child_id=ada.id,
)
await coordinator.async_refresh()
assert hass.entities[
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1
await _reload(hass, entry)
assert hass.entities[
f"nestquest_child_{ADA}_quests_completed_today"
].native_value == 0
f"nestquest_child_{ada.id}_quests_completed_today"
].native_value == 1, "completed count survives the restart"
assert hass.entities[
f"nestquest_child_{BO}_quests_completed_today"
].native_value == 1, "the API's recorded completion survives the restart"
f"nestquest_child_{bo.id}_quests_completed_today"
].native_value == 0
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
assert hass.entities[
"nestquest_household_quests_completed_today"
].native_value == 1
async def test_unload_shuts_coordinator_before_closing_database(
hass, make_entry
) -> None:
"""The coordinator's listeners are cleared while the database is
still connected: a scheduled refresh must never race the close."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
database = entry.runtime_data.database
seen_connected = []
async def _probe_shutdown():
seen_connected.append(database.connected)
original_close = database.close
async def _recording_close():
seen_connected.append("closing")
await original_close()
coordinator.async_shutdown = _probe_shutdown
database.close = _recording_close
assert await async_unload_entry(hass, entry) is True
assert seen_connected == [True, "closing"], (
"coordinator shutdown must precede the database close"
)
+137 -214
View File
@@ -2,156 +2,72 @@
One fixture drives every assertion the done-condition names: correct
values for every sensor and binary sensor (including the zero-quest
child and the 255-character state limit), the completed/uncompleted/
day-complete events re-fired exactly once from the API's SSE frames,
the panel payload omitting missed instances while admin_instances
equals it, and no entities for a child the API does not report. The
per-task test files cover each behavior in depth; this file is the
single pass that proves them all against ONE fixture, so a regression
in any contract fails here too.
Since the DB removal the household state and every transition come
from the API service, so the fixture scripts the panel route's
payloads and completion surface (:class:`conftest.ScriptedPanelClient`)
and delivers the transition frames through the SSE subscription — the
production event path.
child and the 255-character state limit), the three events firing
exactly once with schema-valid payloads, the instances payload
omitting missed instances while admin_instances includes them, and no
entities for an inactive child. The per-task test files cover each
behavior in depth; this file is the single pass that proves them all
against ONE fixture, so a regression in any contract fails here too.
"""
from __future__ import annotations
import datetime
import re
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
from conftest import refire_api_transitions, wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import (
create_child,
list_children,
set_child_active,
)
from custom_components.nestquest.completion import derive_state
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
EVENT_CHILD_DAY_COMPLETE,
EVENT_QUEST_COMPLETED,
EVENT_QUEST_UNCOMPLETED,
SERVICE_COMPLETE_QUEST,
SERVICE_UNCOMPLETE_QUEST,
)
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
ADA = 1
BO = 2
CORY = 3
_OCCURRED = "2026-09-23T07:55:00+00:00"
def _instance(instance_id: int, child_id: int, *, window="morning", state="open"):
return {
"id": instance_id,
"definition_id": 3,
"child_id": child_id,
"title": "Brush teeth",
"icon": "🦷",
"window": window,
"due_time": "08:00" if window == "morning" else "19:00",
"state": state,
"overdue": False,
"completed_at": None if state == "open" else _OCCURRED,
"on_time": None if state == "open" else True,
}
def _child(child_id: int, name: str, instances=()):
completed = sum(1 for i in instances if i["state"] == "completed")
due = len(instances)
return {
"child_id": child_id,
"child_name": name,
"present": True,
"next_present": None,
"due_today": due,
"completed_today": completed,
"remaining_today": due - completed,
"completion_pct": 100 if due == 0 else round(completed / due * 100),
"instances": list(instances),
}
def _payload(children, cycle_day=0) -> dict:
return {
"today_iso": "2026-09-23",
"cycle_day": cycle_day,
"children": list(children),
}
def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str):
"""The completed-event frame the API service publishes for one
panel completion (the day-complete rule is the API's; its payload
shape is pinned by tests/test_api_sse.py on the production
plane)."""
return (
EVENT_QUEST_COMPLETED,
{
"child_id": child_id,
"child_name": child_name,
"instance_id": instance_id,
"quest_title": "Brush teeth",
"window": window,
"due_date": "2026-09-23",
"due_time": "08:00" if window == "morning" else "19:00",
"occurred_at": _OCCURRED,
"was_on_time": True,
},
)
def _day_complete_frame(child_id: int, child_name: str):
"""The day-complete frame the API publishes once a child's day
clears (after the LAST completion of the day)."""
return (
EVENT_CHILD_DAY_COMPLETE,
{
"child_id": child_id,
"child_name": child_name,
"quests_due": 2,
"quests_completed": 2,
"occurred_at": _OCCURRED,
},
)
async def _fixture(hass, make_entry):
"""Two quest children, one zero-quest child; the inactive child is
simply ABSENT from the API's snapshot (the API only reports active
children, so the integration can never create its entities)."""
"""Two quest children, one zero-quest child, one inactive child."""
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
),
hass.registry,
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
)
client = ScriptedPanelClient(
_payload(
[
_child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]),
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
_child(CORY, "Cory"),
]
),
_payload(
[
_child(
ADA,
"Ada",
[
_instance(7, ADA, state="completed"),
_instance(8, ADA, window="evening", state="completed"),
],
),
_child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]),
_child(CORY, "Cory"),
]
),
)
set_coordinator_client(entry, client)
assert await async_setup_entry(hass, entry) is True
return entry, client
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
cory = await create_child(database, "Cory")
ghost = await create_child(database, "Ghost")
await set_child_active(database, ghost.id, False)
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning", "evening"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
await entry.runtime_data.coordinator.async_refresh()
return entry, (ada, bo, cory, ghost)
def _sensor(hass, child_id, kind):
@@ -159,11 +75,11 @@ def _sensor(hass, child_id, kind):
async def test_entity_and_event_matrix(hass, make_entry) -> None:
entry, client = await _fixture(hass, make_entry)
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
coordinator = entry.runtime_data.coordinator
# --- Every reported child has the full entity set; the child the
# API does not report has none.
# --- Every active child has the full entity set; the inactive
# child has none.
kinds = (
"quests_due_today",
"quests_completed_today",
@@ -173,37 +89,36 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
"all_done",
"present_today",
)
ghost_id = 99
for child in (ADA, BO, CORY):
for child in (ada, bo, cory):
for kind in kinds:
assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
for kind in kinds:
assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, (
"children absent from the API snapshot must have no entities"
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
"inactive children must have no entities"
)
# --- Correct values against the fixture: Ada and Bo owe two
# quests (morning + evening), Cory owes none and is still 100%.
for child in (ADA, BO):
assert _sensor(hass, child, "quests_due_today").native_value == 2
for child in (ada, bo):
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
assert (
_sensor(hass, child, "quests_completed_today").native_value == 0
_sensor(hass, child.id, "quests_completed_today").native_value == 0
)
assert (
_sensor(hass, child, "quests_remaining_today").native_value == 2
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
)
assert (
_sensor(hass, child, "completion_pct_today").native_value == 0
_sensor(hass, child.id, "completion_pct_today").native_value == 0
)
assert _sensor(hass, child, "next_quest").native_value == (
assert _sensor(hass, child.id, "next_quest").native_value == (
"Brush teeth"
)
assert _sensor(hass, child, "all_done").is_on is False
assert _sensor(hass, child, "present_today").is_on is True
assert _sensor(hass, CORY, "quests_due_today").native_value == 0
assert _sensor(hass, CORY, "completion_pct_today").native_value == 100
assert _sensor(hass, CORY, "all_done").is_on is False
assert _sensor(hass, CORY, "next_quest").native_value == "none"
assert _sensor(hass, child.id, "all_done").is_on is False
assert _sensor(hass, child.id, "present_today").is_on is True
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
assert _sensor(hass, cory.id, "all_done").is_on is False
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
# --- Household rollups equal the per-child sums.
assert hass.entities[
@@ -223,31 +138,27 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
), unique_id
# --- Completing both of Ada's quests fires the events exactly
# once each and flips her entities immediately. The transition
# events arrive over the API's SSE stream (the subscription
# re-fires them), never from the service handler.
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
# once each and flips her entities immediately.
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": 8, "actor": "panel", "actor_child_id": ADA},
for instance in instances:
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance.id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
# The transition events arrive over the API's SSE stream (the
# subscription re-fires them), never from the service handler.
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
client.published_frames.extend(
[
_completed_frame(7, ADA, "Ada", "morning"),
_completed_frame(8, ADA, "Ada", "evening"),
# The API publishes the day-complete frame only when the
# day clears — after the LAST completion of the day.
_day_complete_frame(ADA, "Ada"),
]
)
from conftest import refire_api_transitions
await refire_api_transitions(hass, entry, client)
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
assert _TIMESTAMP.match(payload["occurred_at"])
@@ -256,53 +167,56 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
assert payload["quest_title"] == "Brush teeth"
assert isinstance(payload["instance_id"], int)
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
# The next scripted snapshot reflects both completions.
await coordinator.async_refresh()
assert _sensor(hass, ADA, "all_done").is_on is True
assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0
assert _sensor(hass, ada.id, "all_done").is_on is True
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
assert (
_sensor(hass, ADA, "completion_pct_today").native_value == 100
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
)
# --- Reversing one quest fires the uncompleted event exactly once.
# The HA ``uncomplete_quest`` service is gone (the API service's
# admin route is the reversal path), so the reversal is the API's:
# it publishes the uncompleted frame and the integration re-fires
# it; the coordinator's next poll picks the restored counts up.
client.published_frames.append(
(
EVENT_QUEST_UNCOMPLETED,
{
"child_id": ADA,
"child_name": "Ada",
"instance_id": 7,
"quest_title": "Brush teeth",
"window": "morning",
"due_date": "2026-09-23",
"due_time": "08:00",
"occurred_at": _OCCURRED,
},
)
# --- Reversing one quest fires the uncompleted event exactly once
# and restores the owed counts.
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": instances[0].id, "actor": "user"},
context={"user_id": "admin-1"},
)
await refire_api_transitions(hass, entry, client)
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
assert _sensor(hass, ada.id, "all_done").is_on is False
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
# --- The panel payload omits missed instances (D-009) — the API
# panel route omits missed rows ENTIRELY, so admin_instances
# cannot carry them either (the admin surface for missed quests is
# the PWA, reading the API directly). A snapshot built from the
# payload can only ever hold open and completed rows: pin that
# the payload round-trips verbatim.
# --- The instances payload omits missed instances (D-009) — and
# since Feature 18 the snapshot comes from the API panel route,
# which omits missed rows ENTIRELY, so admin_instances cannot
# carry them either (the admin surface for missed quests is the
# PWA, reading the API directly): pin the derive clock forward in
# the TEST ONLY so today's open instances read missed.
original_refresh = coordinator._async_update_data
async def _future_dated_refresh():
import custom_components.nestquest.core.snapshot as snapshot_module
real_derive = snapshot_module.derive_state
def _derive(instance, latest, today):
return real_derive(instance, latest, today + datetime.timedelta(days=1))
snapshot_module.derive_state = _derive
try:
return await original_refresh()
finally:
snapshot_module.derive_state = real_derive
coordinator._async_update_data = _future_dated_refresh
await coordinator.async_refresh()
attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes
assert attributes["instances"] == [
dict(_instance(9, BO)),
dict(_instance(10, BO, window="evening")),
], "the panel payload round-trips the API's open rows verbatim"
assert attributes["admin_instances"] == attributes["instances"], (
attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes
assert attributes["instances"] == [], "panel payload omits missed"
assert attributes["admin_instances"] == [], (
"the API payload omits missed rows, so the admin payload "
"carries none either (the PWA is the admin surface now)"
)
coordinator._async_update_data = original_refresh
await coordinator.async_refresh()
async def test_payload_schema_matches_the_documented_contract(
@@ -310,16 +224,25 @@ async def test_payload_schema_matches_the_documented_contract(
) -> None:
"""Every fired payload carries the documented fields with the
documented types."""
entry, client = await _fixture(hass, make_entry)
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
instances = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
{
"instance_id": instances[0].id,
"actor": "panel",
"actor_child_id": ada.id,
},
)
# The completed event arrives over the API's SSE stream.
await refire_api_transitions(
hass, entry, entry.runtime_data.coordinator.api_client
)
client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning"))
from conftest import refire_api_transitions
await refire_api_transitions(hass, entry, client)
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
for key, check in (
("child_id", lambda v: isinstance(v, int)),
+733 -221
View File
File diff suppressed because it is too large Load Diff
+502 -272
View File
@@ -9,103 +9,27 @@ 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 (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_TIME,
DEFAULT_HORIZON_DAYS,
SERVICE_REGENERATE,
DOMAIN as DOMAIN_CONST,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.dashboard import (
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_URL_PATH,
async_register_dashboard,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _admin_data():
return {CONF_ADMIN_USER_IDS: [ADMIN_ID]}
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
@@ -113,25 +37,15 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
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)
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
entry = _wire(make_entry(options={"horizon_days": 7}), 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 runtime.settings.horizon_days == 7
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)
@@ -206,7 +120,7 @@ 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}
entry.options = {"horizon_days": 30}
await registry.async_dispatch_options_update(entry)
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
@@ -284,16 +198,6 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
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 = []
@@ -312,9 +216,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
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
async def test_setup_closes_database_when_listener_registration_fails() -> None:
"""A listener-registration failure must not leak the opened database."""
from custom_components.nestquest.db import NestQuestDatabase
class _Boom(Exception):
pass
@@ -324,16 +228,13 @@ async def test_setup_unwinds_when_listener_registration_fails() -> None:
hass, _registry = make_hass()
with pytest.raises(_Boom):
await async_setup_entry(hass, entry)
assert hass.data.get(DOMAIN_CONST, {}) == {}
assert hass.data.get(DOMAIN, {}) == {}
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
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
"""Unload removes the listener, closes the DB in a finally, then raises."""
from custom_components.nestquest.db import NestQuestDatabase
class _RemoveBoom(Exception):
pass
@@ -345,175 +246,504 @@ async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> N
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
database = entry.runtime_data.database
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert seen == ["shutdown"], "coordinator shutdown must have run"
assert database.connected is False, "database leaked despite remover error"
assert entry.runtime_data is None
assert DOMAIN_CONST not in hass.data
assert DOMAIN not in hass.data
assert hass.time_change.size == 0, "time-change listener leaked"
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
"""A raising update-listener remover must not skip the time-change cancel.
Both removers are independent: the update-listener remover raising must
still cancel the daily time-change callback, whose action would otherwise
fire after unload against a closed database.
"""
class _RemoveBoom(Exception):
pass
def _remover():
raise _RemoveBoom()
entry = make_config_entry(entry_id="update-boom-time-cancel")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
assert hass.time_change.size == 1
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert hass.time_change.size == 0, "time-change listener not cancelled"
assert entry.runtime_data is None
assert DOMAIN not in hass.data
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
"""Setup registers a daily rollover listener and stores its cancel fn."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert registration["hour"] == 0
assert registration["minute"] == 0
assert callable(entry.runtime_data.remove_time_change_listener)
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
"""The listener fires at the entry option's rollover time, not the default."""
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
await async_setup_entry(hass, entry)
registration = hass.time_change.registrations[0]
assert registration["hour"] == 6
assert registration["minute"] == 30
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
"""Unload removes the time-change listener alongside the update listener."""
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert hass.time_change.size == 1
assert await async_unload_entry(hass, entry) is True
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(
async def test_rollover_time_change_reregisters_listener_at_new_time(
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.
"""A rollover-time options change re-registers the listener at the new time."""
entry = _wire(
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
)
await async_setup_entry(hass, entry)
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
# The options flow writes the new rollover time, then HA reloads the
# entry: unload tears down the old listener, setup re-registers it.
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
await async_unload_entry(hass, entry)
assert hass.time_change.size == 0
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
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert (registration["hour"], registration["minute"]) == (6, 30)
async def test_setup_leaves_an_existing_user_dashboard_alone(
hass, make_entry
async def test_day_rollover_listener_materializes_ha_local_horizon(
hass, make_entry, monkeypatch
) -> 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
"""Firing the listener materializes today..today+horizon in HA local time."""
import datetime
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
import custom_components.nestquest as nestquest
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
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
fake = AsyncMock()
monkeypatch.setattr(nestquest, "_materialize_run", fake)
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
expected_start = today.isoformat()
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
await hass.time_change.fire()
fake.assert_awaited_once_with(
database, expected_start, expected_end, today=today
)
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")
async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
"""The daily run materializes only the configured horizon window."""
import datetime
collection = _RefusingCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
database = entry.runtime_data.database
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),
# A definition created AFTER setup has no instances yet (create does not
# materialize); firing the daily listener must generate only the horizon.
child_id = await _seed_daily_child_and_definition(
database, _local_today(hass)
)
assert results == [True, True]
assert len(collection.created) == 1
assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1
await hass.time_change.fire()
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
Setup drives the local-time wrapper directly, so passing ``local=`` must
raise TypeError — otherwise a regression in the production call would be
silently masked by the fake.
"""
import inspect as _inspect
from homeassistant.helpers.event import async_track_time_change
params = _inspect.signature(async_track_time_change).parameters
assert list(params) == ["hass", "action", "hour", "minute", "second"]
with pytest.raises(TypeError):
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
rows = await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name IN ('children', 'admin_users', 'schedule_rules', "
"'quest_definitions') ORDER BY name"
)
assert [row[0] for row in rows] == [
"admin_users",
"children",
"quest_definitions",
"schedule_rules",
]
await async_unload_entry(hass, entry)
async def _seed_daily_child_and_definition(database, today) -> int:
"""Create one always-present child + a daily definition starting today.
Returns the child's id. No presence schedule means the child is present
every day, so the daily rule yields exactly one instance per horizon day.
"""
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
NOW = "2026-09-14T12:00:00+00:00"
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
[child.id],
["morning"],
)
return child.id
def _local_today(hass) -> "datetime.date":
import datetime
from zoneinfo import ZoneInfo
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
"""Setup materializes once so a restart backfills any missed days."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
# Seed the database BEFORE setup: a restart reopens an existing file that
# already holds definitions. The startup backfill must pick them up.
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None:
"""Setup backfills only the configured (non-default) horizon window."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, today.isoformat(), end)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, beyond, beyond_end) == []
async def test_setup_succeeds_with_fully_malformed_options(
hass, make_entry
) -> None:
"""A fully malformed options dict falls back per field and setup succeeds.
``horizon_days=True``, ``day_rollover_time='99:99'`` and
``morning_summary_time='25:00'`` are all invalid; the integration's
``from_options_resilient`` falls each back to its default, so setup
SUCCEEDS, the day-rollover listener registers at the default hour 0
minute 0, and the startup backfill materializes the default horizon
window.
"""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass.async_add_executor_job)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(
make_entry(
options={
CONF_HORIZON_DAYS: True,
CONF_DAY_ROLLOVER_TIME: "99:99",
CONF_MORNING_SUMMARY_TIME: "25:00",
}
),
hass.registry,
)
assert await async_setup_entry(hass, entry) is True
# The day-rollover listener fell back to the default 00:00.
registration = hass.time_change.registrations[0]
assert registration["hour"] == 0
assert registration["minute"] == 0
# The startup backfill used the default horizon window.
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child_id, today.isoformat(), end)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
hass, make_entry
) -> None:
"""A malformed sibling option does not reset the configured horizon.
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
so the entry's resolved settings keep ``horizon_days == 5``.
"""
entry = _wire(
make_entry(
options={
CONF_HORIZON_DAYS: 5,
CONF_MORNING_SUMMARY_TIME: "25:00",
}
),
hass.registry,
)
assert await async_setup_entry(hass, entry) is True
assert entry.runtime_data.settings.horizon_days == 5
async def test_regenerate_service_registered_and_materializes(
hass, make_entry
) -> None:
"""``nestquest.regenerate`` is registered and calling it regenerates."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(data=_admin_data()), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# A definition created AFTER setup has no instances yet (create does not
# materialize); the service must generate them on demand.
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_regenerate_service_uses_configured_horizon_days(
hass, make_entry
) -> None:
"""``nestquest.regenerate`` regenerates only the configured horizon."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(
make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()),
hass.registry,
)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass)
end = (today + datetime.timedelta(days=5)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == 6
beyond = (today + datetime.timedelta(days=6)).isoformat()
beyond_end = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
assert await QuestInstancesDao(database).list_by_date_range(
child_id, beyond, beyond_end
) == []
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert await async_unload_entry(hass, entry) is True
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
async def test_regenerate_service_is_domain_scoped_across_entries(
hass, make_entry
) -> None:
"""The ``regenerate`` service is domain-global, not per-entry.
With two loaded entries the service must be registered and functional;
unloading one entry must keep it registered and functional for the
remaining entry; unloading the final entry must remove it.
"""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry_a = _wire(
make_entry(entry_id="entry_a", data=_admin_data()), hass.registry
)
entry_b = _wire(
make_entry(entry_id="entry_b", data=_admin_data()), hass.registry
)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# The domain-global handler resolves a live database from any entry.
database_b = entry_b.runtime_data.database
child_id = await _seed_daily_child_and_definition(
database_b, _local_today(hass)
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database_b).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
# Unloading one entry must not tear down the shared service.
assert await async_unload_entry(hass, entry_a) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# It still works for the remaining entry.
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
# Unloading the final entry removes the domain-global service.
assert await async_unload_entry(hass, entry_b) is True
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
async def test_setup_cleans_listeners_when_materialization_raises(
hass, make_entry, monkeypatch
) -> None:
"""A startup-materialization failure rolls back every registered listener.
The daily and update listeners are installed before the startup
materialization runs; when that walk raises, the database is closed but
the remover callables must also be invoked so no listener survives
firing against a closed database.
"""
import custom_components.nestquest as nestquest
async def _boom(*_args, **_kwargs):
raise RuntimeError("materialization failed")
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
entry = _wire(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="materialization failed"):
await async_setup_entry(hass, entry)
assert hass.time_change.size == 0
assert hass.registry.size == 0
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert not hasattr(entry, "runtime_data")
+16 -16
View File
@@ -6,29 +6,29 @@ import datetime
import pytest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
schedule_rule_to_storage,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.materialize import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.materialize import (
materialize,
regenerate_for_child,
regenerate_for_definition,
)
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import (
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
edit_quest_definition,
)
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
ScheduleRule,
occurs_on,
@@ -505,7 +505,7 @@ def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None:
def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
@@ -628,7 +628,7 @@ def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None:
def test_materialize_honours_overrides(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
@@ -758,7 +758,7 @@ def test_materialize_clamps_past_start_to_today(tmp_path) -> None:
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.core.quest_definitions import (
from custom_components.nestquest.quest_definitions import (
set_quest_definition_active,
)
@@ -906,7 +906,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history(
tmp_path,
) -> None:
async def _body(database):
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
@@ -1091,7 +1091,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
"""Regenerating one child must never touch another child's rows."""
async def _body(database):
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
@@ -1493,10 +1493,10 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
async def _body(database):
from custom_components.nestquest.core.dao_presence import (
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
from custom_components.nestquest.core.presence import PresenceSchedule
from custom_components.nestquest.presence import PresenceSchedule
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
+134 -4
View File
@@ -9,16 +9,16 @@ from unittest.mock import AsyncMock
import pytest
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
from custom_components.nestquest.core.migrations import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL
from custom_components.nestquest.migrations import (
MIGRATIONS,
VERSION_TABLE,
VERSION_TABLE_DDL,
apply_migrations,
read_schema_version,
)
from custom_components.nestquest.core.schema import (
from custom_components.nestquest.schema import (
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
)
@@ -1064,3 +1064,133 @@ def test_partial_pending_migrations_resume_from_current_version(
assert "step_b" in _tables(database)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Startup wiring: setup runs the migration runner, not raw DDL
# ---------------------------------------------------------------------------
async def _setup_entry(hass, entry, registry) -> object:
from tests.test_lifecycle import _wire # reuse the established wiring
entry = _wire(entry, registry)
from custom_components.nestquest import async_setup_entry
await async_setup_entry(hass, entry)
return entry
async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
import sqlite3 as sqlite3_mod
from custom_components.nestquest.migrations import read_schema_version
from custom_components.nestquest.store import async_get_db_path
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
assert await read_schema_version(database) == len(MIGRATIONS)
# The v1 tables exist because the runner applied migration 1.
names = {
row[0]
for row in await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name NOT LIKE 'sqlite_%'"
)
}
assert "children" in names and "completion_events" in names
# The version table exists with its singleton row stamped.
columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})")
assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)]
db_path = await async_get_db_path(hass)
conn = sqlite3_mod.connect(db_path)
try:
conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)")
violated = False
except sqlite3_mod.IntegrityError:
violated = True
finally:
conn.close()
assert violated, "singleton-row CHECK must be live in the setup-created DB"
await database.close()
async def test_options_reload_reopens_and_rechecks_version(
hass, make_entry
) -> None:
"""A listener-triggered reload re-runs setup against the same file.
Unload then setup is the reload lifecycle: the old connection is
closed, apply_migrations runs again on the persisted file, keeps
version and rows intact, and the reopened connection is live.
"""
from custom_components.nestquest import async_unload_entry
from custom_components.nestquest.migrations import read_schema_version
entry = await _setup_entry(hass, make_entry(), hass.registry)
database_a = entry.runtime_data.database
await database_a.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
assert await async_unload_entry(hass, entry) is True
assert not database_a.connected
entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry)
database_b = entry_reloaded.runtime_data.database
assert database_b is not database_a
try:
assert await read_schema_version(database_b) == len(MIGRATIONS)
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
assert row == (1,)
finally:
await database_b.close()
async def test_setup_entry_failure_closes_database(
hass, make_entry, monkeypatch
) -> None:
"""A failing migration closes the opened connection before re-raising.
A migration failure surfaces as ConfigEntryNotReady (HA retries
setup) with the original error preserved as __cause__. Spies on
the real wrapper instance: records whether close() ran, rather
than inferring from hass.data (a failed setup never stores a
runtime record).
"""
import pytest as pytest_module
from homeassistant.exceptions import ConfigEntryNotReady
import custom_components.nestquest as nestquest_module
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import (
MIGRATIONS as _MIGRATIONS,
)
async def _failing(database, migrations=_MIGRATIONS):
raise RuntimeError("boom")
monkeypatch.setattr(
nestquest_module, "apply_migrations", _failing, raising=True
)
close_calls: list[bool] = []
original_close = NestQuestDatabase.close
async def _spy_close(self):
close_calls.append(True)
return await original_close(self)
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
entry = make_entry()
with pytest_module.raises(ConfigEntryNotReady) as excinfo:
await _setup_entry(hass, entry, hass.registry)
assert isinstance(excinfo.value.__cause__, RuntimeError)
assert "boom" in str(excinfo.value.__cause__)
assert close_calls == [True], (
"failed setup must close the connection it opened"
)
record = hass.data["nestquest"].get(entry.entry_id)
assert record is None, "failed setup must not store runtime data"
+503 -47
View File
@@ -14,16 +14,33 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
from custom_components.nestquest import config_flow
from custom_components.nestquest import options_flow
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_AFTERNOON_REMINDER_TIME,
CONF_API_BASE_URL,
CONF_CELEBRATION_ENABLED,
CONF_DAY_ROLLOVER_TIME,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_END_OF_DAY_REPORT_TIME,
CONF_HORIZON_DAYS,
CONF_MORNING_SUMMARY_ENABLED,
CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET,
CONF_PANEL_IDLE_TIMEOUT,
CONF_PANEL_TOKEN,
CONF_SNAPSHOT_STALENESS,
CONF_UPDATE_INTERVAL,
DEFAULT_API_BASE_URL,
DEFAULT_PANEL_IDLE_TIMEOUT,
DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED,
DEFAULT_END_OF_DAY_REPORT_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_PANEL_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_PANEL_IDLE_TIMEOUT,
DOMAIN,
)
@@ -32,17 +49,27 @@ STRINGS_PATH = PKG_DIR / "strings.json"
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
VALID_INPUT = {
CONF_HORIZON_DAYS: 7,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_PANEL_IDLE_TIMEOUT: 60,
}
#: What a schema-validated submit produces from a bare VALID_INPUT on a
#: fresh entry: the Feature 10 refresh interval, the Feature 18
#: staleness threshold, and the Feature 18 API connection fill from
#: fresh entry: the Feature 10 refresh interval, the Feature 11
#: notification section, and the Feature 18 API connection fill from
#: their defaults.
FULL_INPUT = {
**VALID_INPUT,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
CONF_NOTIFY_TARGET: "",
CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME,
CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME,
CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME,
CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED,
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
}
@@ -60,13 +87,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
return make_config_entry(entry_id=entry_id, options=options, data=data)
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
"""Create an options flow instance bound to a stubbed config entry."""
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
"""Create an options flow instance bound to a stubbed config entry.
``users`` populates a stubbed hass.auth for the admin picker;
``database`` wires the runtime record so the flow can read and
write the allowlist like real HA's options flow does.
"""
from unittest.mock import AsyncMock
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
flow.hass = SimpleNamespace()
flow.hass.auth = SimpleNamespace(
async_get_users=AsyncMock(return_value=users or [])
)
if database is not None:
from custom_components.nestquest.const import DOMAIN
flow.hass.data = {
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
}
return flow
def _user(user_id: str, name: str):
return SimpleNamespace(id=user_id, name=name)
def _open_allowlist_db(tmp_path, name):
"""A real migrated temp database for the flow to read and write."""
from unittest.mock import AsyncMock, MagicMock
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
database = NestQuestDatabase(hass.async_add_executor_job)
_run(database.open(tmp_path / name))
_run(apply_migrations(database))
return database
def _schema_values(schema) -> dict:
"""Extract the per-field default values from a real voluptuous schema."""
return {
@@ -93,11 +155,11 @@ def test_options_flow_get_options_flow_is_ha_callback() -> None:
def test_options_flow_constructs_through_2024_6_surface() -> None:
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
assert isinstance(flow, _options_flow_base())
assert flow.config_entry is entry
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
assert flow.options == {CONF_HORIZON_DAYS: 21}
def test_options_flow_submits_through_2024_6_surface() -> None:
@@ -132,66 +194,86 @@ def test_options_flow_prefills_from_defaults() -> None:
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL
assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
def test_options_flow_prefills_from_entry_options() -> None:
"""Stored entry options win over entry data and defaults."""
entry = _make_entry(
options={
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
},
data={
CONF_HORIZON_DAYS: 99,
CONF_DAY_ROLLOVER_TIME: "23:59",
CONF_PANEL_IDLE_TIMEOUT: 999,
CONF_UPDATE_INTERVAL: 999,
CONF_SNAPSHOT_STALENESS: 999,
},
)
result = _run(_make_flow(entry).async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_HORIZON_DAYS] == 21
assert values[CONF_DAY_ROLLOVER_TIME] == "05:45"
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
assert values[CONF_UPDATE_INTERVAL] == 90
assert values[CONF_SNAPSHOT_STALENESS] == 450
def test_options_flow_falls_back_to_entry_data() -> None:
"""Pre-upgrade entries without options fall back to entry.data."""
entry = _make_entry(
data={
CONF_HORIZON_DAYS: 10,
CONF_DAY_ROLLOVER_TIME: "01:15",
CONF_PANEL_IDLE_TIMEOUT: 90,
CONF_UPDATE_INTERVAL: 60,
CONF_SNAPSHOT_STALENESS: 240,
}
)
result = _run(_make_flow(entry).async_step_init(None))
values = _schema_values(result["data_schema"])
assert values[CONF_HORIZON_DAYS] == 10
assert values[CONF_DAY_ROLLOVER_TIME] == "01:15"
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
assert values[CONF_UPDATE_INTERVAL] == 60
assert values[CONF_SNAPSHOT_STALENESS] == 240
def test_options_flow_schema_applies_stored_defaults() -> None:
"""A real voluptuous validate({}) fills every field from the stored defaults."""
"""A real voluptuous validate({}) fills every field from the stored defaults.
Without a runtime database (and no auth in the bare harness), the
admin picker's default is the empty selection and its choice set is
empty — the three legacy settings are unaffected.
"""
entry = _make_entry(
options={
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
CONF_API_BASE_URL: "http://panel.lan:8443",
CONF_PANEL_TOKEN: "stored-token",
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
CONF_MORNING_SUMMARY_TIME: "07:00",
CONF_AFTERNOON_REMINDER_TIME: "14:00",
CONF_END_OF_DAY_REPORT_TIME: "21:30",
}
)
result = _run(_make_flow(entry).async_step_init(None))
assert result["data_schema"]({}) == {
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_UPDATE_INTERVAL: 90,
CONF_SNAPSHOT_STALENESS: 450,
CONF_API_BASE_URL: "http://panel.lan:8443",
CONF_PANEL_TOKEN: "stored-token",
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
CONF_MORNING_SUMMARY_TIME: "07:00",
CONF_AFTERNOON_REMINDER_TIME: "14:00",
CONF_END_OF_DAY_REPORT_TIME: "21:30",
CONF_MORNING_SUMMARY_ENABLED: True,
CONF_AFTERNOON_REMINDER_ENABLED: True,
CONF_END_OF_DAY_REPORT_ENABLED: True,
CONF_CELEBRATION_ENABLED: True,
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
CONF_ADMIN_USER_IDS: [],
}
@@ -201,14 +283,55 @@ def test_options_flow_schema_coerces_field_types() -> None:
result = _run(flow.async_step_init(None))
schema = result["data_schema"]
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
assert validators[CONF_HORIZON_DAYS] is int
assert validators[CONF_DAY_ROLLOVER_TIME] is str
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
assert validators[CONF_UPDATE_INTERVAL] is int
assert validators[CONF_SNAPSHOT_STALENESS] is int
# The Feature 18 API connection fields are free-text strings.
assert validators[CONF_API_BASE_URL] is str
assert validators[CONF_PANEL_TOKEN] is str
@pytest.mark.parametrize("bad_horizon", [0, -5])
def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None:
"""Zero or negative horizon values yield a field-level error, no exception."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"}
def test_options_flow_rejects_malformed_time_not_a_time() -> None:
"""Non-HH:MM strings are rejected with invalid_time."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
def test_options_flow_rejects_malformed_time_out_of_range() -> None:
"""Out-of-range HH:MM values are rejected with invalid_time."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
def test_options_flow_rejects_pasted_unicode_superscript_time() -> None:
"""Pasted Unicode digits like '²²:²²' yield a field-level error, no exception."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
"""Non-numeric or too-low idle timeouts yield a field-level error."""
@@ -220,21 +343,10 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29])
def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None:
"""Non-numeric, boolean, and below-floor update intervals reject."""
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"}
def test_options_flow_creates_entry_with_validated_keys() -> None:
"""A valid submit creates an entry with exactly the validated keys
(the client-facing settings, filled from defaults where the raw
submit omitted them)."""
(the legacy settings plus every later section's keys, filled from
defaults where the raw submit omitted them)."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
@@ -249,7 +361,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
entry = _make_entry()
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
# Simulate setup registering the lifecycle update listener.
# Simulate setup registering the merged lifecycle update listener.
from custom_components.nestquest import async_setup_entry
_run(async_setup_entry(hass, entry))
@@ -268,13 +380,14 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
def test_options_flow_validation_never_raises() -> None:
"""Validation returns errors instead of raising for arbitrary junk input."""
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None}
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(junk))
assert result["type"] == "form"
assert result["errors"] == {
CONF_HORIZON_DAYS: "invalid",
CONF_DAY_ROLLOVER_TIME: "invalid_time",
CONF_PANEL_IDLE_TIMEOUT: "invalid",
CONF_UPDATE_INTERVAL: "invalid",
}
@@ -304,10 +417,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di
assert init["title"]
assert init["description"]
data = init["data"]
for key in (CONF_PANEL_IDLE_TIMEOUT,):
for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT):
assert data.get(key), f"Missing field name for '{key}'"
errors = options["errors"]
for key in ("invalid",):
for key in ("invalid", "invalid_time"):
assert errors.get(key), f"Missing error string for '{key}'"
@@ -363,6 +476,349 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
)
# ---------------------------------------------------------------------------
# Admin allowlist picker (Feature 03)
# ---------------------------------------------------------------------------
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
"""The picker offers EXISTING HA users (id -> name), never free text."""
import voluptuous as vol
database = _open_allowlist_db(tmp_path, "picker-users.db")
try:
from custom_components.nestquest.admin_allowlist import set_admin_ids
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(flow.async_step_init(None))
schema = result["data_schema"]
marker = next(
m for m in schema.schema
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
)
# Default is the CURRENT database allowlist.
values = _schema_values(schema)
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
# Both users are selectable; an unknown id is rejected by the
# schema itself (this is what real HA submits go through).
validated = schema(
{
CONF_HORIZON_DAYS: 7,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_PANEL_IDLE_TIMEOUT: 60,
CONF_ADMIN_USER_IDS: ["u1", "u2"],
}
)
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
with pytest.raises(vol.Invalid):
schema(
{
CONF_HORIZON_DAYS: 7,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_PANEL_IDLE_TIMEOUT: 60,
CONF_ADMIN_USER_IDS: ["impostor"],
}
)
finally:
_run(database.close())
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
database = _open_allowlist_db(tmp_path, "picker-apply.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
)
assert result["type"] == "create_entry"
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
# The allowlist (and the entry's recovery copy) carry the pick.
assert await_gather(list_admin_ids(database)) == ["u2"]
finally:
_run(database.close())
def await_gather(coro):
return _run(coro)
def test_options_flow_empty_selection_refused(tmp_path) -> None:
"""Saving zero admins is refused: an empty allowlist fails closed."""
database = _open_allowlist_db(tmp_path, "picker-empty.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
"""Raw callers omitting the key (pre-picker payloads) do not
touch the allowlist; the CURRENT allowlist is carried forward on
the options copy so no removed admin can be resurrected later."""
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"]
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_duplicate_selection_refused(tmp_path) -> None:
"""Duplicate ids surface as a field error, not an unhandled
ValueError from the business layer."""
database = _open_allowlist_db(tmp_path, "picker-dupe.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_omitted_picker_carries_allowlist_forward(
tmp_path,
) -> None:
"""An omitted picker field must not drop the allowlist copy from
the returned options: HA replaces entry.options wholesale, and a
later database loss must not resurrect removed admins from the
stale config-flow copy in entry.data."""
database = _open_allowlist_db(tmp_path, "picker-carry.db")
try:
from custom_components.nestquest.admin_allowlist import (
set_admin_ids,
)
_run(set_admin_ids(database, ["u2"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
# The current allowlist rides along on the legacy three-key path.
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
finally:
_run(database.close())
def test_options_flow_notification_section_defaults() -> None:
"""A bare submit fills the notification section from const defaults:
08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight
rollover sweep — an empty notify target, and all toggles enabled."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00"
assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00"
assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00"
assert result["data"][CONF_NOTIFY_TARGET] == ""
for toggle in (
CONF_MORNING_SUMMARY_ENABLED,
CONF_AFTERNOON_REMINDER_ENABLED,
CONF_END_OF_DAY_REPORT_ENABLED,
CONF_CELEBRATION_ENABLED,
):
assert result["data"][toggle] is True
def test_options_flow_notification_times_must_be_strict_hh_mm() -> None:
flow = _make_flow(_make_entry())
for bad_time in ("9:00", "25:00", "08:0", "08-00", 800):
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_MORNING_SUMMARY_TIME: bad_time,
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"}
def test_options_flow_notify_target_validation() -> None:
"""The empty target is VALID (it is the shipped default and must be
savable); only malformed non-empty values reject."""
flow = _make_flow(_make_entry())
# The empty default saves cleanly.
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_NOTIFY_TARGET: ""}
)
)
assert result["type"] == "create_entry"
assert result["data"][CONF_NOTIFY_TARGET] == ""
# Malformed non-empty values reject.
for bad_target in (" notify.x ", " ", 42):
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_NOTIFY_TARGET: bad_target,
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"}
def test_options_flow_toggles_must_be_real_booleans() -> None:
flow = _make_flow(_make_entry())
result = _run(
flow.async_step_init(
{
**VALID_INPUT,
CONF_CELEBRATION_ENABLED: "on",
}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"}
def test_options_flow_notification_stored_values_round_trip() -> None:
"""Stored notification settings are the form's defaults and a raw
submit without them keeps the stored values."""
entry = _make_entry(
options={
**VALID_INPUT,
CONF_UPDATE_INTERVAL: 90,
CONF_NOTIFY_TARGET: "notify.mobile_app_dad",
CONF_MORNING_SUMMARY_TIME: "07:15",
CONF_AFTERNOON_REMINDER_TIME: "16:45",
CONF_END_OF_DAY_REPORT_TIME: "21:00",
CONF_MORNING_SUMMARY_ENABLED: False,
CONF_AFTERNOON_REMINDER_ENABLED: True,
CONF_END_OF_DAY_REPORT_ENABLED: False,
CONF_CELEBRATION_ENABLED: True,
}
)
flow = _make_flow(entry)
form = _run(flow.async_step_init(None))
schema_defaults = form["data_schema"]({})
assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15"
assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False
# A raw submit carrying only the legacy keys keeps the stored
# notification settings.
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad"
assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15"
assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False
assert result["data"][CONF_CELEBRATION_ENABLED] is True
def test_options_flow_no_hard_coded_personal_target_in_source() -> None:
"""The notify target's DEFAULT is the empty string: the household
fills their own service name, and no personal target ships as a
value anywhere in the const/flow defaults."""
from custom_components.nestquest import const
assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target"
# No const default carries a concrete notify service.
assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == ""
form = _run(_make_flow(_make_entry()).async_step_init(None))
assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == ""
# ---------------------------------------------------------------------------
# Panel-plane API connection section (Feature 18)
@@ -433,7 +889,7 @@ def test_options_flow_api_section_stored_values_round_trip() -> None:
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
# A raw submit carrying only the panel-idle key keeps the stored
# A raw submit carrying only the legacy keys keeps the stored
# API connection settings.
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
@@ -520,7 +976,7 @@ def test_options_flow_staleness_equal_to_interval_saves() -> None:
def test_options_flow_staleness_defaults_saved() -> None:
"""A bare submit saves the staleness default (three update
intervals) alongside the other defaults."""
intervals) alongside the other timing defaults."""
flow = _make_flow(_make_entry())
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
@@ -542,7 +998,7 @@ def test_options_flow_staleness_stored_values_round_trip() -> None:
schema_defaults = form["data_schema"]({})
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
# A raw submit carrying only the panel-idle key keeps the stored
# A raw submit carrying only the legacy keys keeps the stored
# staleness (and never trips the interval cross-check).
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
+2 -400
View File
@@ -5,30 +5,16 @@ the contract by reading the card sources and the built bundle, in the same
spirit as test_frontend.py. They cover the states decided in
design/PANEL-EMPTY-STATES.md and PANEL-SPEC.md, and guard the kid-facing
bundles against ever gaining a parent-only service call.
The cards additionally have render tests: they drive the jsdom harnesses in
frontend/tests — render-party-board.mjs imports the built bundle into a
jsdom window and reports the ordered plates the shadow DOM renders plus
where a crest tap navigates, and render-quest-log.mjs reports where the
quest log's idle return navigates — so the zero-config discovery and
navigation contracts are asserted against the bundle HACS ships.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
FRONTEND_DIR = REPO_ROOT / "frontend"
FRONTEND_CARDS_DIR = FRONTEND_DIR / "src" / "cards"
FRONTEND_CARDS_DIR = REPO_ROOT / "frontend" / "src" / "cards"
PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
QUEST_LOG_HARNESS = FRONTEND_DIR / "tests" / "render-quest-log.mjs"
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
@@ -171,388 +157,4 @@ def test_panel_cards_reference_no_admin_only_services() -> None:
assert BUNDLE_PATH.is_file()
bundle = _read(BUNDLE_PATH)
assert "nestquest-party-board-card" in bundle
assert "nestquest-quest-log-card" in bundle
def test_party_board_discovers_children_from_the_household_roster() -> None:
"""Feature 20: with no child_order configured, the board discovers
the ordered child slugs from the household rollup's child_roster
attribute; explicit configuration still wins."""
source = _read(PARTY_BOARD)
assert "child_roster" in source
assert "sensor.nestquest_household_quests_due_today" in source
# Discovery only fills in for a MISSING child_order; a supplied
# order array is honoured verbatim before the discovered list is
# ever consulted.
assert "Array.isArray(order)" in source
assert "_discoveredChildSlugs()" in source
# --- Render tests (built bundle under jsdom) ---------------------------------
#: The seeded three-child household, in the API's sort order: Ada and
#: Bo share one daily quest (Bo is away today), Cory has none.
ROSTER = [
{"child_id": 1, "name": "Ada", "slug": "ada"},
{"child_id": 2, "name": "Bo", "slug": "bo"},
{"child_id": 3, "name": "Cory", "slug": "cory"},
]
def _state(state: str, **attributes) -> dict:
return {"state": state, "attributes": attributes}
def _three_child_states() -> dict:
"""The hass states a three-child household exposes.
Entity ids follow the documented naming contract
(design/ENTITIES-AND-SERVICES.md §1): the household rollup and the
per-child sensors slugged from the child names.
"""
return {
"sensor.nestquest_household_quests_due_today": _state(
"2",
children={"1": 1, "2": 1, "3": 0},
child_roster=ROSTER,
),
"sensor.nestquest_ada_quests_due_today": _state(
"1", child_name="Ada", present=True
),
"sensor.nestquest_ada_quests_completed_today": _state("0"),
"sensor.nestquest_ada_completion_pct_today": _state("0"),
"binary_sensor.nestquest_ada_present_today": _state("on"),
"sensor.nestquest_bo_quests_due_today": _state(
"1", child_name="Bo", present=False
),
"sensor.nestquest_bo_quests_completed_today": _state("0"),
"sensor.nestquest_bo_completion_pct_today": _state("0"),
"binary_sensor.nestquest_bo_present_today": _state("off"),
"sensor.nestquest_cory_quests_due_today": _state(
"0", child_name="Cory", present=True
),
"sensor.nestquest_cory_quests_completed_today": _state("0"),
"sensor.nestquest_cory_completion_pct_today": _state("100"),
"binary_sensor.nestquest_cory_present_today": _state("on"),
}
def _render_party_board(
config: dict,
states: dict,
url: str | None = None,
click_plate: str | None = None,
) -> dict:
"""Render the built bundle's party board and return its plates.
Drives frontend/tests/render-party-board.mjs: the harness imports
the committed bundle (the artifact HACS ships) into a jsdom window,
mounts the card with the given config and hass snapshot, optionally
taps the named plate, and prints the ordered plates its shadow DOM
renders, which of them are tappable buttons, and the pathname after
the optional tap.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert RENDER_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"tag": "nestquest-party-board-card",
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
if url is not None:
spec["url"] = url
if click_plate is not None:
spec["click_plate"] = click_plate
completed = subprocess.run(
["node", str(RENDER_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"render harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_party_board_renders_discovered_children_in_sort_order() -> None:
"""No child_order key: three plates render in the roster's sort
order (the coordinator snapshot's order), one per child."""
result = _render_party_board(
{"type": "custom:nestquest-party-board-card"},
_three_child_states(),
)
assert result["plates"] == [
{"name": "Ada", "kind": "present"},
{"name": "Bo", "kind": "away"},
{"name": "Cory", "kind": "present"},
]
def test_explicit_child_order_still_wins_over_discovery() -> None:
"""A supplied child_order keeps working: it overrides the household
roster's order and limits the plates to the configured slugs."""
result = _render_party_board(
{
"type": "custom:nestquest-party-board-card",
"child_order": ["bo", "ada"],
},
_three_child_states(),
)
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
assert "Cory" not in [plate["name"] for plate in result["plates"]]
# --- Dashboard strategy tests -------------------------------------------------
STRATEGY_NAME = "nestquest-party"
def _generate_dashboard(config: dict, states: dict, url: str | None = None) -> dict:
"""Generate the strategy's dashboard from the built bundle.
Drives frontend/tests/render-strategy.mjs: the harness imports the
committed bundle into a jsdom window at the given dashboard URL and
prints the views ``window.customStrategies["nestquest-party"].
generate(config, hass)`` returns.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert STRATEGY_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url or "http://homeassistant.local/nestquest/party",
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
completed = subprocess.run(
["node", str(STRATEGY_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"strategy harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_strategy_is_registered_on_the_bundle() -> None:
"""The strategy ships in the same bundle as the cards and registers
itself under window.customStrategies so a dashboard can use
strategy: { type: custom:nestquest-party }."""
source = _read(STRATEGY_SOURCE)
assert "customStrategies" in source
assert f'["{STRATEGY_NAME}"]' in source or f'"{STRATEGY_NAME}"' in source
bundle = _read(BUNDLE_PATH)
assert "nestquest-party" in bundle
def test_strategy_generates_views_for_three_children() -> None:
"""For the seeded three-child household the strategy generates one
party-board view plus one quest-log view per child in roster order,
with COMPUTED paths: the board view at the dashboard root, each log
view at the child's slug so quest-log.ts's childSlugFromPath reads
the child from the URL's last segment."""
result = _generate_dashboard({}, _three_child_states())
views = result["views"]
assert len(views) == 4
assert [view["path"] for view in views] == [
"party",
"ada",
"bo",
"cory",
]
assert [view["title"] for view in views] == [
"The Party",
"Ada's Quest Log",
"Bo's Quest Log",
"Cory's Quest Log",
]
def test_strategy_every_view_is_panel_type() -> None:
"""Every generated view carries ``type: "panel"``: Home Assistant
defaults an unspecified view to Masonry, which constrains the
1080px-tall NestQuest card to a narrow column (README's TouchHub
setup warns of exactly this); with a generated dashboard there is
no editor step left to set Panel by hand."""
result = _generate_dashboard({}, _three_child_states())
for view in result["views"]:
assert view["type"] == "panel"
def test_strategy_log_view_titles_use_the_roster_display_name() -> None:
"""Quest-log view titles come from the roster's ``name`` field (the
child's display name), not the lowercased entity-id slug; the slug
stays the view's path."""
result = _generate_dashboard(
{},
{
"sensor.nestquest_household_quests_due_today": _state(
"0",
child_roster=[
{"child_id": 7, "name": "Milo", "slug": "milo"},
],
),
},
)
views = result["views"]
assert len(views) == 2
assert views[1]["path"] == "milo"
assert views[1]["title"] == "Milo's Quest Log"
def test_strategy_passes_weather_entity_through_to_the_cards() -> None:
"""An explicit ``weather_entity`` in the strategy configuration is
passed through to the generated card configs (both the board and
the quest log render the weather dock when it is set)."""
result = _generate_dashboard(
{"weather_entity": "weather.home"},
_three_child_states(),
)
views = result["views"]
for view in views:
assert view["cards"][0]["weather_entity"] == "weather.home"
# And without it, no weather_entity key is emitted at all.
unset = _generate_dashboard({}, _three_child_states())
for view in unset["views"]:
assert "weather_entity" not in view["cards"][0]
def test_strategy_wires_navigation_between_the_views() -> None:
"""The board's quest_log_path and each log's board_path are computed
to the dashboard root the strategy renders at, so party-board.ts's
crest tap pushes <root>/<slug> and quest-log.ts's idle return goes
back to the board — neither path is configured by hand."""
dashboard_url = "http://homeassistant.local/nestquest"
root = "/nestquest"
result = _generate_dashboard(
{}, _three_child_states(), url=dashboard_url
)
views = result["views"]
board = views[0]["cards"][0]
assert board["type"] == "custom:nestquest-party-board-card"
assert board["quest_log_path"] == root
for slug, view in zip(("ada", "bo", "cory"), views[1:]):
assert view["cards"][0]["type"] == "custom:nestquest-quest-log-card"
assert view["cards"][0]["board_path"] == root
def test_strategy_zero_children_still_generates_the_board() -> None:
"""With an empty roster (NestQuest not set up yet) the strategy
renders the board view alone — the board card shows its own
"not set up yet" notice for an empty roster — and never crashes."""
result = _generate_dashboard({}, {})
assert len(result["views"]) == 1
assert result["views"][0]["path"] == "party"
def test_strategy_config_overrides_still_apply() -> None:
"""Explicit strategy configuration wins where the design allows: an
explicit url_path pins the computed dashboard root."""
result = _generate_dashboard(
{"url_path": "/wallboard"},
_three_child_states(),
)
root = "/wallboard"
assert result["views"][0]["cards"][0]["quest_log_path"] == root
for view in result["views"][1:]:
assert view["cards"][0]["board_path"] == root
# --- Navigation tests (strategy-computed paths under jsdom) -------------------
def _render_quest_log(
config: dict,
states: dict,
url: str,
idle_ms: int | None = None,
) -> dict:
"""Render the built bundle's quest log and report its location.
Drives frontend/tests/render-quest-log.mjs: the harness imports the
committed bundle (the artifact HACS ships) into a jsdom window at
``url`` — the card resolves its child from the pathname's last
segment — waits the optional idle window, and prints the pathname
and the rendered title.
"""
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
assert QUEST_LOG_HARNESS.is_file()
spec = {
"bundle": str(BUNDLE_PATH),
"url": url,
"config": config,
"hass": {"config": {"time_zone": "UTC"}, "states": states},
}
if idle_ms is not None:
spec["idle_ms"] = idle_ms
completed = subprocess.run(
["node", str(QUEST_LOG_HARNESS)],
input=json.dumps(spec),
capture_output=True,
text=True,
timeout=120,
)
assert completed.returncode == 0, (
f"quest-log harness failed: {completed.stderr}"
)
return json.loads(completed.stdout)
def test_present_crest_tap_navigates_to_the_child_quest_log() -> None:
"""Tapping a present crest pushes <dashboard root>/<slug>: the exact
URL the strategy computed for that child's quest log view (the log
then resolves its child from the path's last segment)."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Ada",
)
# Only the present adventurers' plates are tappable buttons.
assert result["tappable"] == ["Ada", "Cory"]
assert result["location"] == "/nestquest/ada"
def test_away_plate_does_not_navigate() -> None:
"""Bo is away today: his plate is not a button, and tapping it leaves
the URL at the dashboard root — away adventurers cannot open a log."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
board_config = generated["views"][0]["cards"][0]
result = _render_party_board(
board_config,
_three_child_states(),
url="http://homeassistant.local/nestquest",
click_plate="Bo",
)
assert "Bo" not in result["tappable"]
assert result["location"] == "/nestquest"
def test_quest_log_idle_return_navigates_to_the_board() -> None:
"""After the idle window the quest log returns to the board using the
strategy-computed board_path (the dashboard root) — and it resolved
Ada from the URL's last segment first. ``idle_return_seconds`` is
set by hand because the card's 40-second default is too slow for a
test; the strategy never generates that knob."""
generated = _generate_dashboard(
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
)
log_config = {**generated["views"][1]["cards"][0], "idle_return_seconds": 1}
result = _render_quest_log(
log_config,
_three_child_states(),
url="http://homeassistant.local/nestquest/ada",
idle_ms=2000,
)
assert result["headline"] == "Ada's Quest Log"
assert result["location"] == "/nestquest"
assert "nestquest-quest-log-card" in bundle
+238
View File
@@ -0,0 +1,238 @@
"""Feature 09 permission test matrix across every registered service."""
from __future__ import annotations
import datetime
import pytest
from homeassistant.exceptions import Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DEFAULT_HORIZON_DAYS,
DOMAIN,
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
SERVICE_UPDATE_QUEST_DEFINITION,
)
from custom_components.nestquest.service_policy import (
ADMIN_ONLY,
OPEN,
SERVICE_POLICY,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
NON_ADMIN_ID = "non-admin"
NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID}
KIOSK_ID = "kiosk-panel"
KIOSK_CTX = {"user_id": KIOSK_ID}
def test_every_registered_service_is_in_the_operation_registry() -> None:
"""The registry is total: a service outside it could never be gated."""
missing = [
service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY
]
extra = [
service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES
]
assert not missing, f"registered but ungated: {missing}"
assert not extra, f"gated but never registered: {extra}"
assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY}
async def _setup(hass, make_entry, *, admin=True):
data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {}
entry = wire_entry_to_registry(make_entry(data=data), hass.registry)
assert await async_setup_entry(hass, entry) is True
return entry
async def _seed(hass, entry) -> dict:
"""Seed one child, one definition, and materialized instances."""
from custom_components.nestquest.children import list_children
from custom_components.nestquest.dao_instances import QuestInstancesDao
database = entry.runtime_data.database
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [(await list_children(database))[0].id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
child = (await list_children(database))[0]
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
instances = await QuestInstancesDao(database).list_by_date_range(
child.id, today, end
)
assert instances
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
return {
"child_id": child.id,
"instance_id": instances[0].id,
"definition_id": instances[0].definition_id,
"override": await PresenceOverridesDao(database).create(
child.id, "2027-01-01", "2027-01-02", False
),
"today": today,
"database": database,
}
def _admin_success_payload(service: str, ctx: dict) -> dict | None:
"""A schema-valid, end-to-end-successful payload per admin service."""
if service == SERVICE_UNCOMPLETE_QUEST:
return {"instance_id": ctx["instance_id"], "actor": "user"}
if service == SERVICE_CREATE_QUEST_DEFINITION:
return {
"title": "Second quest",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [ctx["child_id"]],
"windows": ["afternoon"],
}
if service == SERVICE_UPDATE_QUEST_DEFINITION:
return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"}
if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE:
return {"definition_id": ctx["definition_id"], "is_active": False}
if service == SERVICE_SET_PRESENCE_PATTERN:
return {
"child_id": ctx["child_id"],
"cycle_length_weeks": 1,
"anchor_date": ctx["today"],
"pattern": {0: [0, 1, 2, 3, 4, 5, 6]},
}
if service == SERVICE_CREATE_PRESENCE_OVERRIDE:
return {
"child_id": ctx["child_id"],
"start_date": "2027-03-01",
"end_date": "2027-03-02",
"is_present": False,
}
if service == SERVICE_DELETE_PRESENCE_OVERRIDE:
return {"override_id": ctx["override"].id}
if service == SERVICE_EXPORT_HISTORY_CSV:
return None # raises HomeAssistantError (Feature 13) — not Unauthorized
if service == SERVICE_MANAGE_CHILD:
return {"action": "create", "display_name": "Bo"}
if service == SERVICE_REGENERATE:
return {}
raise AssertionError(f"unexpected service {service}")
_ADMIN_ONLY = [
service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
]
@pytest.mark.parametrize("service", _ADMIN_ONLY)
async def test_admin_succeeds_on_every_admin_only_service(
hass, make_entry, service
) -> None:
from homeassistant.exceptions import HomeAssistantError
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
if service == SERVICE_EXPORT_HISTORY_CSV:
# The gate passes; the Feature 13 stub raises its "not
# implemented" HomeAssistantError — NOT Unauthorized.
with pytest.raises(HomeAssistantError, match="Feature 13"):
await hass.services.call(
DOMAIN, service, None, context=ADMIN_CTX
)
return
await hass.services.call(
DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX
)
@pytest.mark.parametrize("service", _ADMIN_ONLY)
async def test_non_admin_refused_on_every_admin_only_service(
hass, make_entry, service
) -> None:
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
if service != SERVICE_EXPORT_HISTORY_CSV:
await hass.services.call(
DOMAIN,
service,
_admin_success_payload(service, ctx),
context=ADMIN_CTX,
)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
service,
_admin_success_payload(service, ctx),
context=NON_ADMIN_CTX,
)
async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None:
from custom_components.nestquest.completion import instance_state
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": ctx["instance_id"],
"actor": "panel",
"actor_child_id": ctx["child_id"],
},
context=NON_ADMIN_CTX,
)
assert (
await instance_state(ctx["database"], ctx["instance_id"]) == "done"
)
async def test_kiosk_refused_on_uncomplete_and_regenerate(
hass, make_entry
) -> None:
entry = await _setup(hass, make_entry)
ctx = await _seed(hass, entry)
from custom_components.nestquest.admin_allowlist import list_admin_ids
assert KIOSK_ID not in await list_admin_ids(ctx["database"])
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{"instance_id": ctx["instance_id"], "actor": "user"},
context=KIOSK_CTX,
)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX
)
+122
View File
@@ -0,0 +1,122 @@
"""Tests for the Feature 09 permission gate's audit logging."""
from __future__ import annotations
import logging
import re
import pytest
from homeassistant.exceptions import Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
SERVICE_MANAGE_CHILD,
)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
async def _set_up(hass, make_entry):
entry = wire_entry_to_registry(
make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
return entry
async def test_denied_call_logs_warning_with_fields(
hass, make_entry, caplog
) -> None:
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
denials = [
record
for record in caplog.records
if record.levelno == logging.WARNING
and "denied" in record.getMessage()
]
assert len(denials) == 1
message = denials[0].getMessage()
assert SERVICE_MANAGE_CHILD in message
assert "kiosk-panel" in message
assert _TIMESTAMP.search(message), message
async def test_denied_no_context_logs_user_none(
hass, make_entry, caplog
) -> None:
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
)
denials = [
record
for record in caplog.records
if record.levelno == logging.WARNING
and "denied" in record.getMessage()
]
assert len(denials) == 1
message = denials[0].getMessage()
assert "none" in message
assert _TIMESTAMP.search(message), message
async def test_successful_admin_call_logs_debug_with_fields(
hass, make_entry, caplog
) -> None:
caplog.set_level(logging.DEBUG, logger="custom_components.nestquest")
entry = await _set_up(hass, make_entry)
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
allowed = [
record
for record in caplog.records
if record.levelno == logging.DEBUG
and "allowed" in record.getMessage()
]
assert len(allowed) == 1
message = allowed[0].getMessage()
assert SERVICE_MANAGE_CHILD in message
assert ADMIN_ID in message
assert _TIMESTAMP.search(message), message
async def test_denied_log_carries_no_personal_data_beyond_user_id(
hass, make_entry, caplog
) -> None:
"""The log line names the caller's user id only: the payload's
display name must not leak into the audit trail."""
entry = await _set_up(hass, make_entry)
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
denials = [
record.getMessage()
for record in caplog.records
if record.levelno == logging.WARNING and "denied" in record.getMessage()
]
assert denials, "expected a denial log line"
assert "Ada" not in denials[0]
assert "display_name" not in denials[0]
+1 -1
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.presence import (
from custom_components.nestquest.presence import (
MAX_CYCLE_LENGTH_WEEKS,
PresenceEngine,
PresenceOverride,
+11 -11
View File
@@ -7,20 +7,20 @@ import re
import pytest
from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.core.dao_instances import (
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_rules import (
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
ScheduleRulesDao,
schedule_rule_to_storage,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
from custom_components.nestquest.core.quest_definitions import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.quest_definitions import (
CreatedQuestDefinition,
assign_child,
create_quest_definition,
@@ -31,7 +31,7 @@ from custom_components.nestquest.core.quest_definitions import (
set_quest_definition_active,
unassign_child,
)
from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
def _run(coro):
@@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster(
)
bo = await children.create("Bo", NOW)
import custom_components.nestquest.core.dao_rules as dao_rules
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_assignees
started = asyncio.Event()
@@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
start_date="2026-09-14",
)
import custom_components.nestquest.core.dao_rules as dao_rules
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
@@ -1732,7 +1732,7 @@ def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign(
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
)
import custom_components.nestquest.core.dao_rules as dao_rules
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_assignees
started = asyncio.Event()
@@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
start_date="2026-09-14",
)
import custom_components.nestquest.core.dao_rules as dao_rules
import custom_components.nestquest.dao_rules as dao_rules
original_list = dao_rules.QuestDefinitionsDao.list_windows
started = asyncio.Event()
+1 -1
View File
@@ -12,7 +12,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+2 -2
View File
@@ -15,7 +15,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
ScheduleRule,
RuleType,
occurs_on,
@@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None:
module_file = Path(
importlib.util.find_spec(
"custom_components.nestquest.core.recurrence"
"custom_components.nestquest.recurrence"
).origin
)
tree = ast.parse(module_file.read_text())
+2 -2
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
@@ -219,7 +219,7 @@ def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
than a silent wrong answer. Forged via a private enum extension
because ScheduleRule validation rejects unknown names."""
import enum as enum_module
from custom_components.nestquest.core import recurrence as mod
from custom_components.nestquest import recurrence as mod
ForgedType = enum_module.Enum(
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
+4 -4
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
@@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
"from homeassistant.core import HomeAssistant",
"import sqlite3",
"from .db import NestQuestDatabase",
"from custom_components.nestquest.core.db import NestQuestDatabase",
"from custom_components.nestquest.db import NestQuestDatabase",
"from custom_components.nestquest import db",
"import custom_components.nestquest.core.dao_rules",
"import custom_components.nestquest.dao_rules",
"from . import db",
"from . import schema",
"from custom_components.nestquest.core.migrations import apply_migrations",
"from custom_components.nestquest.migrations import apply_migrations",
]
for probe in probes:
offenders = _scan_forbidden_imports(probe)
+1 -1
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+1 -1
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+1 -1
View File
@@ -18,7 +18,7 @@ import random
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+1 -1
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+1 -1
View File
@@ -5,7 +5,7 @@ import datetime
import pytest
from custom_components.nestquest.core.recurrence import (
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
+2 -2
View File
@@ -6,8 +6,8 @@ import sqlite3
import pytest
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.schema import (
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.schema import (
SCHEMA_V1_ADMIN_USERS_DDL,
SCHEMA_V1_CHILDREN_DDL,
SCHEMA_V1_COMPLETION_EVENTS_DDL,
+208 -340
View File
@@ -1,133 +1,75 @@
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2).
Since the DB removal the snapshot comes from the API service's panel
route, so these tests script the route's payload (the documented
Feature 16 shape ``test_coordinator.py`` also uses) through
:class:`conftest.StubSnapshotClient` instead of seeding a local
database: the coordinator rebuilds the same core dataclasses the
sensors read, and every assertion below is on entity state.
"""
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2)."""
from __future__ import annotations
import datetime
import pytest
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.children import create_child
from custom_components.nestquest.completion import complete_instance
from custom_components.nestquest.const import DOMAIN
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import create_quest_definition
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.sensor import (
MAX_STATE_LENGTH,
_slugify,
_state_within_limit,
)
#: The seeding horizon: instances exist for today and a few days out,
#: enough for the day snapshot without slowing the walk down.
SEED_HORIZON_DAYS = 3
def _today_iso() -> str:
return datetime.date.today().isoformat()
#: The documented per-child day sensor kinds (unique_id suffixes).
SENSOR_KINDS = (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
)
def _instance(instance_id: int, child_id: int, **overrides) -> dict:
"""One panel instance in the API route's payload shape."""
payload = {
"id": instance_id,
"definition_id": 3,
"child_id": child_id,
"title": "Brush teeth",
"icon": "🦷",
"window": "morning",
"due_time": "08:00",
"state": "open",
"overdue": False,
"completed_at": None,
"on_time": None,
}
payload.update(overrides)
return payload
def _today() -> datetime.date:
return datetime.date.today()
def _child(child_id: int, name: str, instances=(), **overrides) -> dict:
"""One child in the API route's payload shape, counts precomputed.
The API precomputes the per-child rollups from the full day; the
builder here derives them from the instance list so a payload can
never drift from its own counts.
"""
completed = sum(1 for i in instances if i["state"] == "completed")
due = max(len(instances), 0)
payload = {
"child_id": child_id,
"child_name": name,
"present": True,
"next_present": None,
"due_today": due,
"completed_today": completed,
"remaining_today": due - completed,
"completion_pct": 100 if due == 0 else round(completed / due * 100),
"instances": list(instances),
}
payload.update(overrides)
return payload
def _payload(children) -> dict:
return {
"today_iso": _today_iso(),
"cycle_day": 0,
"children": list(children),
}
#: The seeded household: Ada and Bo share one daily quest, Cory has none.
ADA = 1
BO = 2
CORY = 3
def _seeded_payload() -> dict:
return _payload(
[
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Bo", [_instance(8, BO)]),
_child(CORY, "Cory"),
]
)
def _completed_payload() -> dict:
"""The same household after the API recorded Ada's completion."""
return _payload(
[
_child(
ADA,
"Ada",
[_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)],
),
_child(BO, "Bo", [_instance(8, BO)]),
_child(CORY, "Cory"),
]
)
async def _setup_entry(hass, make_entry, *payloads):
"""Set up the integration with a scripted snapshot client.
The client serves the payloads in order (repeating the last), so
setup's first refresh and any later explicit refresh each get the
scripted state — the same path production takes through the API.
"""
entry = wire_entry_to_registry(
make_entry(
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
async def _seed_children(database):
"""Three active children: Ada and Bo share one daily quest; Cory has none."""
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
cory = await create_child(database, "Cory")
today = _today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
hass.registry,
[ada.id, bo.id],
["morning"],
)
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return ada, bo, cory
async def _setup_seeded_entry(hass, make_entry):
"""Set up the integration, seed three children, refresh: sensors exist.
Children are seeded AFTER setup (the entry's database only exists
once setup opens it), so the sensors appear through the refresh
listener — the same path production takes when a child is added.
"""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
coordinator = entry.runtime_data.coordinator
assert coordinator.api_client is client
return entry, coordinator, client
database = entry.runtime_data.database
ada, bo, cory = await _seed_children(database)
await coordinator.async_refresh()
return entry, coordinator, (ada, bo, cory)
def _snapshot_child(coordinator, child_id):
@@ -143,31 +85,47 @@ def _sensor(hass, child_id, kind):
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
async def _complete_first_instance(database, coordinator, child) -> None:
"""Complete the child's first open instance through the business layer."""
snapshot_child = _snapshot_child(coordinator, child.id)
instance_id = snapshot_child.instances[0].instance_id
await complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=child.id,
)
async def test_every_active_child_gets_five_sensors_with_documented_identity(
hass, make_entry
) -> None:
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), _completed_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
assert hass.config_entries.forwarded_platforms == [
"sensor",
"binary_sensor",
]
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
# Three children x (five sensors + two binary sensors) + three
# household sensors, no duplicates.
# Two quest children plus the zero-quest child: three children x
# (five sensors + two binary sensors) + three household sensors,
# no duplicates.
assert len(hass.entities) == 24
expected = {
ADA: ("Ada", 1, 1, 0, 100),
BO: ("Bo", 1, 0, 1, 0),
CORY: ("Cory", 0, 0, 0, 100),
ada.id: ("Ada", 1, 1, 0, 100),
bo.id: ("Bo", 1, 0, 1, 0),
cory.id: ("Cory", 0, 0, 0, 100),
}
for child_id, (name, due, completed, remaining, pct) in expected.items():
due_entity = _sensor(hass, child_id, "quests_due_today")
completed_entity = _sensor(hass, child_id, "quests_completed_today")
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
pct_entity = _sensor(hass, child_id, "completion_pct_today")
next_entity = _sensor(hass, child_id, "next_quest")
assert due_entity.name == f"NestQuest {name} quests due today"
assert completed_entity.name == (
f"NestQuest {name} quests completed today"
@@ -193,11 +151,11 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity(
async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
due_entity = _sensor(hass, CORY, "quests_due_today")
pct_entity = _sensor(hass, CORY, "completion_pct_today")
due_entity = _sensor(hass, cory.id, "quests_due_today")
pct_entity = _sensor(hass, cory.id, "completion_pct_today")
assert due_entity.native_value == 0
assert pct_entity.native_value == 100, (
"zero quests is a perfect day, not a division error"
@@ -210,11 +168,14 @@ async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
async def test_due_today_attributes_carry_the_panel_payload(
hass, make_entry
) -> None:
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _completed_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
ada_due = _sensor(hass, ADA, "quests_due_today")
ada_due = _sensor(hass, ada.id, "quests_due_today")
attributes = ada_due.extra_state_attributes
assert set(attributes) == {
"instances",
@@ -223,7 +184,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"child_name",
"present",
}
assert attributes["child_id"] == ADA
assert attributes["child_id"] == ada.id
assert attributes["child_name"] == "Ada"
assert attributes["present"] is True
[instance] = attributes["instances"]
@@ -240,7 +201,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"completed_at",
"on_time",
}
assert instance["child_id"] == ADA
assert instance["child_id"] == ada.id
assert instance["title"] == "Brush teeth"
assert instance["window"] == "morning"
assert instance["state"] == "completed", (
@@ -253,7 +214,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
"with nothing missed, admin and panel payloads are identical"
)
bo_due = _sensor(hass, BO, "quests_due_today")
bo_due = _sensor(hass, bo.id, "quests_due_today")
[bo_instance] = bo_due.extra_state_attributes["instances"]
assert bo_instance["state"] == "open"
assert bo_instance["completed_at"] is None
@@ -261,64 +222,64 @@ async def test_due_today_attributes_carry_the_panel_payload(
async def test_due_today_payload_omits_missed_admin_equals_panel(
hass, make_entry
hass, make_entry, monkeypatch
) -> None:
"""D-009: the panel payload omits missed instances.
Since Feature 18 the snapshot comes from the API panel route,
which omits missed rows entirely (the admin surface for missed
quests is the PWA, reading the API directly). The integration
renders exactly what the route sent: a snapshot can never carry a
``missed`` view, ``admin_instances`` equals ``instances``, and the
precomputed counts arrive untouched — pinned here with an open
instance whose derived state reads missed (a past ``due_time``
with the counts already rolled up the way the route builds them).
quests is the PWA, reading the API directly). The entity payload
therefore cannot carry missed rows any more: ``admin_instances``
equals ``instances`` — open and completed rows only. A missed
instance is still derived locally (``due_date`` before the
snapshot's "today"); the pinned-FUTURE clock at the
``derive_state`` boundary makes today's open instances read
missed through the same derivation rule, and this test pins the
change: neither payload carries them, and the count rollups
(precomputed server-side from the full day) are unaffected here
because the API counts arrive per the route's own build.
"""
payload = _payload(
[
_child(
BO,
"Bo",
[
_instance(
8,
BO,
due_time="00:01",
overdue=True,
)
],
)
]
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
import custom_components.nestquest.core.snapshot as snapshot_module
bo_due = _sensor(hass, BO, "quests_due_today")
tomorrow = _today() + datetime.timedelta(days=1)
real_derive_state = snapshot_module.derive_state
def _derive_against_pinned_future(instance, latest, today):
return real_derive_state(instance, latest, tomorrow)
monkeypatch.setattr(
snapshot_module, "derive_state", _derive_against_pinned_future
)
await coordinator.async_refresh()
bo_due = _sensor(hass, bo.id, "quests_due_today")
attributes = bo_due.extra_state_attributes
assert attributes["instances"], "an open row renders in the panel payload"
assert attributes["admin_instances"] == attributes["instances"], (
assert attributes["instances"] == [], (
"missed instances are omitted from the panel payload"
)
assert attributes["admin_instances"] == [], (
"the API payload omits missed rows, so the admin payload "
"carries none either (the PWA is the admin surface now)"
)
# The counts arrive precomputed from the route's own build, which
# rolls the FULL day (any missed row included) into the numbers.
assert _sensor(hass, BO, "quests_completed_today").native_value == 0
assert _sensor(hass, BO, "quests_remaining_today").native_value == 1
# Counts still roll the missed instance up as not-completed: the
# counts arrive precomputed from the route's own build, which rolls
# the FULL day (the missed row included) into the numbers.
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
@pytest.mark.parametrize("kind", (
"quests_due_today",
"quests_completed_today",
"quests_remaining_today",
"completion_pct_today",
))
@pytest.mark.parametrize("kind", SENSOR_KINDS)
async def test_no_sensor_state_exceeds_the_255_character_limit(
hass, make_entry, kind
) -> None:
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
for child_id in (ADA, BO, CORY):
entity = _sensor(hass, child_id, kind)
for child in (ada, bo, cory):
entity = _sensor(hass, child.id, kind)
state = entity.native_value
assert len(str(state)) <= MAX_STATE_LENGTH
@@ -334,13 +295,14 @@ def test_state_within_limit_refuses_overlong_strings() -> None:
async def test_sensors_update_on_refresh_after_a_completion(
hass, make_entry
) -> None:
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), _completed_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
completed_entity = _sensor(hass, ADA, "quests_completed_today")
remaining_entity = _sensor(hass, ADA, "quests_remaining_today")
pct_entity = _sensor(hass, ADA, "completion_pct_today")
due_entity = _sensor(hass, ADA, "quests_due_today")
database = entry.runtime_data.database
completed_entity = _sensor(hass, ada.id, "quests_completed_today")
remaining_entity = _sensor(hass, ada.id, "quests_remaining_today")
pct_entity = _sensor(hass, ada.id, "completion_pct_today")
due_entity = _sensor(hass, ada.id, "quests_due_today")
assert completed_entity.native_value == 0
assert remaining_entity.native_value == 1
assert pct_entity.native_value == 0
@@ -356,6 +318,7 @@ async def test_sensors_update_on_refresh_after_a_completion(
due_entity.async_write_ha_state = _record_write
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
assert completed_entity.native_value == 1
@@ -367,8 +330,8 @@ async def test_sensors_update_on_refresh_after_a_completion(
async def test_repeated_refreshes_do_not_duplicate_entities(
hass, make_entry
) -> None:
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
await coordinator.async_refresh()
await coordinator.async_refresh()
@@ -378,9 +341,8 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
async def test_setup_with_no_children_registers_only_household(
hass, make_entry
) -> None:
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _payload([])
)
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
# No children means no per-child entities; the three household
# rollups still exist (an empty household is a valid 0).
assert set(hass.entities) == {
@@ -392,7 +354,8 @@ async def test_setup_with_no_children_registers_only_household(
async def test_unload_unloads_platforms(hass, make_entry) -> None:
entry, _coordinator, _client = await _setup_entry(hass, make_entry)
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert hass.config_entries.unloaded_platforms == [
"sensor",
@@ -403,23 +366,20 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None:
async def test_rename_propagates_to_name_and_device_label(
hass, make_entry
) -> None:
"""A rename in the API's snapshot shows on the next refresh: the
name and device label re-derive from the snapshot while the
unique_id (and therefore history) is untouched."""
renamed = _payload(
[
_child(ADA, "Mirren", [_instance(7, ADA)]),
_child(BO, "Bo", [_instance(8, BO)]),
_child(CORY, "Cory"),
]
"""A manage_child rename shows on the next refresh: the name and
device label re-derive from the snapshot while the unique_id (and
therefore history) is untouched."""
from custom_components.nestquest.children import edit_child
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), renamed
)
due_sensor = _sensor(hass, ADA, "quests_due_today")
database = entry.runtime_data.database
due_sensor = _sensor(hass, ada.id, "quests_due_today")
original_unique_id = due_sensor.unique_id
assert due_sensor.name.startswith("NestQuest Ada ")
await edit_child(database, ada.id, display_name="Mirren")
await coordinator.async_refresh()
assert due_sensor.name.startswith("NestQuest Mirren ")
@@ -431,11 +391,12 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
hass, make_entry
) -> None:
"""When HA reports platforms still loaded, the unload must NOT
tear down the coordinator the live entities read: it returns False
and keeps the runtime record for a retry."""
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
close the database the live entities read: it returns False and
keeps the runtime record for a retry."""
entry, _coordinator, _children = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
async def _refuse_unload(entry_arg, platforms):
return False
@@ -443,27 +404,26 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
hass.config_entries.async_unload_platforms = _refuse_unload
assert await async_unload_entry(hass, entry) is False
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
assert entry.runtime_data.coordinator is coordinator
assert database.connected is True
async def test_next_quest_sensor_reports_earliest_open_quest(
hass, make_entry
) -> None:
"""The next-quest sensor carries the earliest open quest's title in
state with the full shape in attributes."""
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
next_entity = _sensor(hass, ADA, "next_quest")
next_entity = _sensor(hass, ada.id, "next_quest")
assert next_entity.name == f"NestQuest Ada next quest"
assert next_entity.native_value == "Brush teeth"
attributes = next_entity.extra_state_attributes
assert attributes["title"] == "Brush teeth"
assert attributes["window"] == "morning"
assert attributes["instance_id"] == _snapshot_child(
coordinator, ADA
coordinator, ada.id
).instances[0].instance_id
assert attributes["child_id"] == ADA
assert attributes["child_id"] == ada.id
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
@@ -471,11 +431,14 @@ async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
) -> None:
"""A child with every quest done (and a zero-quest child) reports
the documented sentinel, never an empty string."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _completed_payload()
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
assert _sensor(hass, ADA, "next_quest").native_value == "none"
assert _sensor(hass, CORY, "next_quest").native_value == "none"
database = entry.runtime_data.database
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
assert _sensor(hass, ada.id, "next_quest").native_value == "none"
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
async def test_next_quest_state_truncates_over_the_limit(
@@ -483,15 +446,23 @@ async def test_next_quest_state_truncates_over_the_limit(
) -> None:
"""A quest title longer than the state limit is truncated; the
attribute keeps the full title."""
long_title = "A very long quest title. " * 30
payload = _payload(
[_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])]
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
hass, make_entry
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
next_entity = _sensor(hass, ADA, "next_quest")
full_title = _snapshot_child(
entry.runtime_data.coordinator, ADA
).instances[0].title
database = entry.runtime_data.database
from custom_components.nestquest.quest_definitions import (
edit_quest_definition,
)
snapshot_child = _snapshot_child(coordinator, ada.id)
await edit_quest_definition(
database,
snapshot_child.instances[0].definition_id,
title="A very long quest title. " * 30,
)
await coordinator.async_refresh()
next_entity = _sensor(hass, ada.id, "next_quest")
full_title = _snapshot_child(coordinator, ada.id).instances[0].title
assert len(full_title) > 255
state = next_entity.native_value
assert len(state) <= 255
@@ -502,21 +473,19 @@ async def test_household_rollups_sum_the_active_children(
hass, make_entry
) -> None:
"""Household due/completed equal the sum of the per-child sensors;
the cycle-day sensor carries the coordinator's answer (precomputed
by the API, which resolves the schedule engine server-side)."""
payload = _payload(
[
_child(
ADA,
"Ada",
[_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)],
),
_child(BO, "Bo", [_instance(8, BO)]),
_child(CORY, "Cory"),
]
the cycle-day sensor carries the coordinator's answer."""
import datetime as _dt
from custom_components.nestquest.dao_presence import (
PresenceSchedulesDao,
)
payload["cycle_day"] = 4
entry, coordinator, _client = await _setup_entry(hass, make_entry, payload)
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
hass, make_entry
)
database = entry.runtime_data.database
await _complete_first_instance(database, coordinator, ada)
await coordinator.async_refresh()
household_due = hass.entities["nestquest_household_quests_due_today"]
household_completed = hass.entities[
@@ -524,12 +493,12 @@ async def test_household_rollups_sum_the_active_children(
]
cycle_day_entity = hass.entities["nestquest_cycle_day"]
per_child_due = [
_sensor(hass, child_id, "quests_due_today").native_value
for child_id in (ADA, BO, CORY)
_sensor(hass, child.id, "quests_due_today").native_value
for child in (ada, bo, cory)
]
per_child_completed = [
_sensor(hass, child_id, "quests_completed_today").native_value
for child_id in (ADA, BO, CORY)
_sensor(hass, child.id, "quests_completed_today").native_value
for child in (ada, bo, cory)
]
assert household_due.native_value == sum(per_child_due)
assert household_completed.native_value == sum(per_child_completed)
@@ -538,117 +507,16 @@ async def test_household_rollups_sum_the_active_children(
"NestQuest household quests completed today"
)
assert cycle_day_entity.name == "NestQuest cycle day"
assert cycle_day_entity.native_value == 4
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
assert cycle_day_entity.native_value == 0, "no schedule anywhere"
async def test_household_due_sensor_publishes_the_ordered_child_roster(
hass, make_entry
) -> None:
"""Feature 20: the rollup carries the ordered child roster beside
the unchanged per-child ``children`` counts, so the panel cards can
discover the board without a configured child_order. The order is
the coordinator snapshot's (the API's sort_order, then id)."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload()
# A schedule on the FIRST active child drives cycle day.
today = _dt.date.today()
await PresenceSchedulesDao(database).upsert_by_child(
ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(),
"0,1,2,3,4,5,6|",
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Bo", "slug": "bo"},
{"child_id": CORY, "name": "Cory", "slug": "cory"},
]
# The pre-existing per-child counts attribute is untouched.
assert attributes["children"] == {
str(ADA): 1,
str(BO): 1,
str(CORY): 0,
}
async def test_household_roster_tracks_the_snapshot_on_refresh(
hass, make_entry
) -> None:
"""The roster re-derives from every snapshot: a reorder and a
rename in the API payload show up (with the re-derived slug) on the
next refresh, like the sensor names already do."""
reordered = _payload(
[
_child(CORY, "Cory"),
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Mirren", [_instance(8, BO)]),
]
)
entry, coordinator, _client = await _setup_entry(
hass, make_entry, _seeded_payload(), reordered
)
roster_entity = hass.entities["nestquest_household_quests_due_today"]
assert [entry["slug"] for entry in roster_entity.extra_state_attributes["child_roster"]] == [
"ada",
"bo",
"cory",
]
await coordinator.async_refresh()
assert roster_entity.extra_state_attributes["child_roster"] == [
{"child_id": CORY, "name": "Cory", "slug": "cory"},
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Mirren", "slug": "mirren"},
]
async def test_duplicate_display_names_get_registry_suffix_slugs(
hass, make_entry
) -> None:
"""Two children may share a nickname (the children layer allows
it); the roster's slugs then mirror the registry's collision
suffixing (_2, _3, ...) in snapshot order, so both plates resolve."""
payload = _payload(
[
_child(ADA, "Ada", [_instance(7, ADA)]),
_child(BO, "Ada"),
]
assert cycle_day_entity.native_value == 4
assert cycle_day_entity.extra_state_attributes["today"] == (
today.isoformat()
)
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
assert hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes["child_roster"] == [
{"child_id": ADA, "name": "Ada", "slug": "ada"},
{"child_id": BO, "name": "Ada", "slug": "ada_2"},
]
async def test_empty_household_publishes_an_empty_roster(
hass, make_entry
) -> None:
"""An empty household is a valid 0: the roster is an empty list,
and the card's discovery of it renders the not-set-up notice."""
entry, _coordinator, _client = await _setup_entry(
hass, make_entry, _payload([])
)
attributes = hass.entities[
"nestquest_household_quests_due_today"
].extra_state_attributes
assert attributes["child_roster"] == []
assert attributes["children"] == {}
def test_slugify_mirrors_home_assistant_entity_id_derivation() -> None:
"""The slug mirror matches homeassistant.util.slugify for the names
children are registered with (verified against HA's own slugify)."""
assert _slugify("Ada") == "ada"
assert _slugify("Mary Jane") == "mary_jane"
assert _slugify("Mary-Jane") == "mary_jane"
assert _slugify("O'Brien") == "o_brien"
assert _slugify("Émilie") == "emilie"
assert _slugify("Åsa") == "asa"
assert _slugify(" Bo ") == "bo"
# HA's wrapper answers "unknown" when a name slugs to nothing.
assert _slugify(" ") == "unknown"
assert _slugify("?!?") == "unknown"
# The registry caps the entity_id at 64 characters, so the slugged
# object id alone is capped at 64 - len("sensor.").
assert _slugify("a" * 80) == "a" * (64 - len("sensor."))
+32
View File
@@ -0,0 +1,32 @@
"""Tests for the Feature 09 admin-only versus open service registry."""
from __future__ import annotations
from custom_components.nestquest.const import (
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.service_policy import (
ADMIN_ONLY,
OPEN,
SERVICE_POLICY,
)
def test_policy_covers_exactly_the_registered_services() -> None:
assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES)
def test_only_complete_quest_is_open() -> None:
open_services = {
name for name, access in SERVICE_POLICY.items() if access == OPEN
}
assert open_services == {SERVICE_COMPLETE_QUEST}
assert all(
access == ADMIN_ONLY
for name, access in SERVICE_POLICY.items()
if name != SERVICE_COMPLETE_QUEST
)
def test_policy_values_are_only_open_or_admin_only() -> None:
assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY}
+472 -40
View File
@@ -1,13 +1,4 @@
"""Tests for NestQuest service declarations and domain-global handlers.
Since Feature 18 the integration is a client of the NestQuest API
service: the only HA service left is the panel completion path
(``nestquest.complete_quest``, a proxy to the API's panel complete
route). Every admin service was removed — the admin surface is the
PWA and the API service's admin routes — so these tests pin the
shrunk surface: services.yaml declares nothing else, setup registers
nothing else, and no removed admin service name may come back.
"""
"""Tests for NestQuest service declarations and domain-global handlers."""
from __future__ import annotations
import re
@@ -15,16 +6,27 @@ from pathlib import Path
import pytest
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
DOMAIN_SERVICES,
SERVICE_COMPLETE_QUEST,
SERVICE_CREATE_PRESENCE_OVERRIDE,
SERVICE_CREATE_QUEST_DEFINITION,
SERVICE_DELETE_PRESENCE_OVERRIDE,
SERVICE_EXPORT_HISTORY_CSV,
SERVICE_MANAGE_CHILD,
SERVICE_REGENERATE,
SERVICE_SET_PRESENCE_PATTERN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
SERVICE_UNCOMPLETE_QUEST,
)
from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY
SERVICES_PATH = (
Path(__file__).parent.parent
@@ -33,27 +35,21 @@ SERVICES_PATH = (
/ "services.yaml"
)
#: The admin services removed in Feature 18. None of these names may
#: be registered as an HA service again — the admin surface is the PWA
#: and the API service's admin routes.
REMOVED_ADMIN_SERVICES = (
"uncomplete_quest",
"create_quest_definition",
"update_quest_definition",
"set_quest_definition_active",
"set_presence_pattern",
"create_presence_override",
"delete_presence_override",
"export_history_csv",
"manage_child",
"regenerate",
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
ADMIN_ID = "admin-1"
ADMIN_CTX = {"user_id": ADMIN_ID}
def _make_admin_entry(make_entry):
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]})
def _service_section(text: str, name: str) -> str:
match = re.search(rf"^{name}:\s*$", text, re.MULTILINE)
assert match is not None, f"services.yaml must declare {name!r}"
@@ -81,33 +77,42 @@ def test_services_yaml_declares_every_canonical_service() -> None:
)
def test_services_yaml_no_longer_declares_removed_admin_services() -> None:
"""The admin services are gone from the HA service surface."""
def test_services_yaml_declares_regenerate() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
for name in REMOVED_ADMIN_SERVICES:
assert not re.search(rf"^{name}:\s*$", text, re.MULTILINE), (
f"removed admin service {name!r} must not be declared"
)
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
"services.yaml must declare the 'regenerate' service"
)
def test_services_yaml_canonical_services_declare_fields() -> None:
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
"""The regenerate service is documented with a clear description and no
required fields (there is no ``fields`` block under it)."""
text = SERVICES_PATH.read_text(encoding="utf-8")
section = _service_section(text, "regenerate")
assert re.search(r"description:", section), (
"regenerate must carry a description"
)
assert "fields:" not in section, (
"regenerate declares no required fields"
)
def test_services_yaml_other_services_declare_fields() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
for name in DOMAIN_SERVICES:
if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV):
continue
section = _service_section(text, name)
assert "fields:" in section, f"{name} must declare fields"
async def test_only_complete_quest_registered_after_setup(
async def test_all_canonical_services_registered_after_setup(
hass, make_entry
) -> None:
"""Setup registers exactly the panel completion service — and no
removed admin service under any name."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
for service in DOMAIN_SERVICES:
assert hass.services.has_service(DOMAIN, service), service
for service in REMOVED_ADMIN_SERVICES:
assert not hass.services.has_service(DOMAIN, service), service
async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None:
@@ -133,6 +138,271 @@ async def test_unload_one_of_two_entries_keeps_services(
assert not hass.services.has_service(DOMAIN, service), service
async def test_manage_child_create_persists_child(hass, make_entry) -> None:
from custom_components.nestquest.children import list_children
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
children = await list_children(entry.runtime_data.database)
assert [child.display_name for child in children] == ["Ada"]
async def test_create_and_set_quest_definition_active(
hass, make_entry
) -> None:
from custom_components.nestquest.children import list_children
from custom_components.nestquest.quest_definitions import (
list_active_definitions,
)
from custom_components.nestquest.dao_rules import QuestDefinitionsDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
active = await list_active_definitions(entry.runtime_data.database)
assert len(active) == 1
definition_id = active[0].definition.id
await hass.services.call(
DOMAIN,
SERVICE_SET_QUEST_DEFINITION_ACTIVE,
{"definition_id": definition_id, "is_active": False},
context=ADMIN_CTX,
)
assert await list_active_definitions(entry.runtime_data.database) == []
stored = await QuestDefinitionsDao(entry.runtime_data.database).get(
definition_id
)
assert stored is not None
assert stored.is_active is False
async def test_complete_and_uncomplete_quest_call_business_layer(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.completion import instance_state
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
records = await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child.id, today, end)
assert records
instance_id = records[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{
"instance_id": instance_id,
"actor": "panel",
"actor_child_id": child.id,
},
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "done"
)
await hass.services.call(
DOMAIN,
SERVICE_UNCOMPLETE_QUEST,
{
"instance_id": instance_id,
"actor": "user",
"actor_user_id": ADMIN_ID,
},
context=ADMIN_CTX,
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "open"
)
async def test_set_presence_pattern_regenerates_child_instances(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
dao = QuestInstancesDao(entry.runtime_data.database)
assert await dao.list_by_date_range(child.id, today, end)
await hass.services.call(
DOMAIN,
SERVICE_SET_PRESENCE_PATTERN,
{
"child_id": child.id,
"cycle_length_weeks": 1,
"anchor_date": today,
"pattern": {0: []},
},
context=ADMIN_CTX,
)
assert await dao.list_by_date_range(child.id, today, end) == []
async def test_presence_override_create_and_delete_regenerate(
hass, make_entry
) -> None:
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.dao_presence import PresenceOverridesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today()
end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
dao = QuestInstancesDao(entry.runtime_data.database)
before = await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
assert before
await hass.services.call(
DOMAIN,
SERVICE_CREATE_PRESENCE_OVERRIDE,
{
"child_id": child.id,
"start_date": today.isoformat(),
"end_date": end.isoformat(),
"is_present": False,
},
context=ADMIN_CTX,
)
assert (
await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
== []
)
override = (
await PresenceOverridesDao(
entry.runtime_data.database
).list_by_child_and_range(
child.id, today.isoformat(), end.isoformat()
)
)[0]
await hass.services.call(
DOMAIN,
SERVICE_DELETE_PRESENCE_OVERRIDE,
{"override_id": override.id},
context=ADMIN_CTX,
)
after = await dao.list_by_date_range(
child.id, today.isoformat(), end.isoformat()
)
assert len(after) == len(before)
async def test_export_history_csv_raises_home_assistant_error(
hass, make_entry
) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(HomeAssistantError, match="Feature 13"):
await hass.services.call(
DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX
)
async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
@@ -144,6 +414,168 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None:
SERVICE_COMPLETE_QUEST,
{"instance_id": True, "actor": "panel", "actor_child_id": 1},
)
with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create"},
)
async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
async def test_admin_only_service_denies_missing_user_context(
hass, make_entry
) -> None:
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
)
async def test_admin_only_service_denies_admin_on_empty_allowlist(
hass, make_entry
) -> None:
"""Even the allowlisted id is denied when the allowlist is empty."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
async def test_unauthorized_is_raised_before_handler_runs(
hass, make_entry
) -> None:
from custom_components.nestquest.children import list_children
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context={"user_id": "kiosk-panel"},
)
assert await list_children(entry.runtime_data.database) == []
_MINIMAL_PAYLOADS = {
"uncomplete_quest": {"instance_id": 1, "actor": "user"},
"create_quest_definition": {
"title": "T",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [1],
"windows": ["morning"],
},
"update_quest_definition": {"definition_id": 1},
"set_quest_definition_active": {"definition_id": 1, "is_active": True},
"set_presence_pattern": {
"child_id": 1,
"cycle_length_weeks": 1,
"anchor_date": "2026-09-19",
"pattern": {0: [0]},
},
"create_presence_override": {
"child_id": 1,
"start_date": "2026-09-19",
"end_date": "2026-09-19",
"is_present": False,
},
"delete_presence_override": {"override_id": 1},
"export_history_csv": {},
"manage_child": {"action": "create", "display_name": "Ada"},
"regenerate": {},
}
_ADMIN_ONLY_SERVICES = [
name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY
]
@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES)
async def test_admin_only_service_fails_closed_without_user_context(
hass, make_entry, service
) -> None:
"""A call with no user_id is denied on every admin-only service."""
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
with pytest.raises(Unauthorized):
await hass.services.call(
DOMAIN, service, dict(_MINIMAL_PAYLOADS[service])
)
async def test_complete_quest_proceeds_without_user_context(
hass, make_entry
) -> None:
"""The open service needs no user context: the kiosk panel taps a
child profile and the call goes through with no HA user at all."""
import datetime
from custom_components.nestquest.children import list_children
from custom_components.nestquest.completion import instance_state
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(_make_admin_entry(make_entry), hass.registry)
assert await async_setup_entry(hass, entry) is True
await hass.services.call(
DOMAIN,
SERVICE_MANAGE_CHILD,
{"action": "create", "display_name": "Ada"},
context=ADMIN_CTX,
)
child = (await list_children(entry.runtime_data.database))[0]
await hass.services.call(
DOMAIN,
SERVICE_CREATE_QUEST_DEFINITION,
{
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child.id],
"windows": ["morning"],
},
context=ADMIN_CTX,
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX)
today = datetime.date.today().isoformat()
end = (
datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
instance_id = (
await QuestInstancesDao(
entry.runtime_data.database
).list_by_date_range(child.id, today, end)
)[0].id
await hass.services.call(
DOMAIN,
SERVICE_COMPLETE_QUEST,
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
)
assert (
await instance_state(entry.runtime_data.database, instance_id)
== "done"
)
async def test_scheduled_internals_never_call_the_service_layer(
@@ -158,4 +590,4 @@ async def test_scheduled_internals_never_call_the_service_layer(
assert "hass.services.call" not in source, (
"scheduled internals must call the materialization directly, "
"never through hass.services.call"
)
)
+472
View File
@@ -0,0 +1,472 @@
"""Tests for startup handling of missing, valid, and corrupt databases."""
from __future__ import annotations
import asyncio
import hashlib
import sqlite3
from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import make_config_entry
from homeassistant.exceptions import ConfigEntryNotReady
from custom_components.nestquest import (
_async_open_database,
_is_corruption_error,
async_setup_entry,
async_unload_entry,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import (
MIGRATIONS,
VERSION_TABLE,
apply_migrations,
)
from tests.test_lifecycle import _wire
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _open_db(path) -> NestQuestDatabase:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
_run(database.open(path))
return database
async def _avalid_database(path) -> None:
"""Create a valid, migrated NestQuest database file (async, on the
running loop, for use inside pytest-asyncio tests)."""
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
try:
await apply_migrations(database)
finally:
await database.close()
def _valid_database(path) -> None:
"""Create a valid, migrated NestQuest database file at ``path``."""
database = _open_db(path)
try:
_run(apply_migrations(database))
finally:
_run(database.close())
def _truncated_database(path) -> None:
"""Create a deliberately truncated (corrupt) database file.
A valid database is written, closed, then cut short mid-header:
opening it reports 'file is not a database' — exactly what an
interrupted write or failing disk leaves behind.
"""
_valid_database(path)
data = path.read_bytes()
assert len(data) > 64, "valid database must exist before truncation"
path.write_bytes(data[: len(data) // 2])
# ---------------------------------------------------------------------------
# Corruption classification
# ---------------------------------------------------------------------------
def test_is_corruption_error_matches_sqlite_spellings() -> None:
assert _is_corruption_error(
sqlite3.DatabaseError("file is not a database")
)
assert _is_corruption_error(
sqlite3.DatabaseError("file is not a database".upper())
)
assert _is_corruption_error(
sqlite3.DatabaseError("file is encrypted or malformed")
)
assert _is_corruption_error(
sqlite3.OperationalError("file is not a database")
)
def test_is_corruption_error_rejects_other_failures() -> None:
assert not _is_corruption_error(
sqlite3.OperationalError("database is locked")
)
assert not _is_corruption_error(
sqlite3.IntegrityError("NOT NULL constraint failed")
)
assert not _is_corruption_error(RuntimeError("boom"))
assert not _is_corruption_error(ValueError("nope"))
# ---------------------------------------------------------------------------
# Startup: three cases through _async_open_database
# ---------------------------------------------------------------------------
def test_missing_file_opens_fresh(tmp_path) -> None:
db_path = tmp_path / "nestquest.db"
assert not db_path.exists()
database = _run(_async_open_database(_make_hass_mock(), db_path))
try:
# The file was created fresh and migrations stamped the
# latest schema version.
assert db_path.exists()
row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
)
assert row == (len(MIGRATIONS),)
finally:
_run(database.close())
def test_zero_byte_existing_file_is_corrupt_and_untouched(tmp_path) -> None:
"""A zero-byte file is neither missing nor valid: it is corrupt.
The leave-untouched contract applies — startup refuses (retryable)
rather than silently initializing over whatever interrupted write
left the empty file, and the file stays exactly zero bytes.
"""
db_path = tmp_path / "nestquest.db"
db_path.write_bytes(b"")
with pytest.raises(ConfigEntryNotReady) as excinfo:
_run(_async_open_database(_make_hass_mock(), db_path))
assert db_path.stat().st_size == 0
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
# No sidecars appeared from the refusal path.
assert not (tmp_path / "nestquest.db-wal").exists()
assert not (tmp_path / "nestquest.db-shm").exists()
def test_valid_file_opens_and_migrations_noop(tmp_path) -> None:
db_path = tmp_path / "nestquest.db"
_valid_database(db_path)
digest_before = hashlib.sha256(db_path.read_bytes()).hexdigest()
database = _run(_async_open_database(_make_hass_mock(), db_path))
try:
row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
)
assert row == (len(MIGRATIONS),)
finally:
_run(database.close())
# Opening a valid file must not rewrite it (the no-op path).
assert (
hashlib.sha256(db_path.read_bytes()).hexdigest() == digest_before
)
def test_corrupt_file_raises_not_ready_and_leaves_file_untouched(
tmp_path, caplog
) -> None:
db_path = tmp_path / "nestquest.db"
_truncated_database(db_path)
corrupt_bytes = db_path.read_bytes()
digest = hashlib.sha256(corrupt_bytes).hexdigest()
with pytest.raises(ConfigEntryNotReady) as excinfo:
_run(_async_open_database(_make_hass_mock(), db_path))
# The file is byte-identical: the owner can restore/inspect it.
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
assert len(db_path.read_bytes()) == len(corrupt_bytes)
# The original error is preserved and the log names the path.
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
assert any(
"corrupt" in record.getMessage().lower()
and str(db_path) in record.getMessage()
for record in caplog.records
if record.levelname == "ERROR"
), "the error log must name the corrupt path"
def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None:
"""The corrupt case must surface as ConfigEntryNotReady, not crash
setup with a raw sqlite3.DatabaseError — and it must ALWAYS raise
(returning normally would mean corruption was silently accepted)."""
db_path = tmp_path / "nestquest.db"
_truncated_database(db_path)
with pytest.raises(ConfigEntryNotReady) as excinfo:
_run(_async_open_database(_make_hass_mock(), db_path))
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
def test_migration_stage_corruption_leaves_db_and_sidecars_untouched(
tmp_path, monkeypatch
) -> None:
"""A valid header with a corrupt LATER page must be rejected by the
whole-file read-only preflight BEFORE the read-write open can
switch the journal mode to WAL.
The fixture is deliberately a ROLLBACK-JOURNAL file (created with
plain sqlite3, default journal_mode=delete, NOT via the wrapper):
that is the case where the WAL transition actually rewrites the
header — a WAL-mode fixture would mask the write under test. The
open() spy proves the read-write open was never reached.
"""
db_path = tmp_path / "nestquest.db"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA journal_mode=DELETE")
conn.execute(
"CREATE TABLE filler (id INTEGER PRIMARY KEY, "
"payload TEXT NOT NULL)"
)
conn.executemany(
"INSERT INTO filler (payload) VALUES (?)",
[(f"row-{index}" * 8,) for index in range(200)],
)
conn.commit()
finally:
conn.close()
data = bytearray(db_path.read_bytes())
assert conn.execute is not None
assert len(data) > 4200, "fixture must span more than one page"
# Corrupt a page well past the 100-byte header (offset 4096, the
# second page region): the header itself stays valid, so the
# read-write open succeeds and only the integrity probe/migrations
# discover damage.
for offset in range(4096, 4128):
data[offset] = 0xFF
db_path.write_bytes(bytes(data))
corrupt_bytes = db_path.read_bytes()
digest = hashlib.sha256(corrupt_bytes).hexdigest()
# Track whether the read-write open is reached at all: the whole
# preflight must reject this corruption BEFORE NestQuestDatabase
# .open() is ever called, which is the only way the file can stay
# byte-identical (the open persists WAL, rewriting the header).
open_calls: list[str] = []
original_open = NestQuestDatabase.open
async def _spy_open(self, path):
open_calls.append(str(path))
return await original_open(self, path)
monkeypatch.setattr(NestQuestDatabase, "open", _spy_open)
with pytest.raises(ConfigEntryNotReady) as excinfo:
_run(_async_open_database(_make_hass_mock(), db_path))
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
# The corruption was caught at the preflight: the read-write open
# (and with it the WAL header rewrite) was never attempted.
assert open_calls == [], (
"whole-file preflight must reject corruption before the "
"read-write open can transition the journal mode"
)
# The main file is byte-identical to the corrupt state we handed in.
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
# No WAL/SHM sidecars left behind (WAL mode was never persisted,
# or they were cleaned up on close).
assert not (tmp_path / "nestquest.db-wal").exists()
assert not (tmp_path / "nestquest.db-shm").exists()
def test_wal_mode_corruption_leaves_file_and_sidecars_untouched(
tmp_path, monkeypatch
) -> None:
"""A corrupt WAL-HEADER file must be rejected without creating
-shm sidecars from the read-only preflight itself.
The immutable=1 probe URI is what guarantees a read-only
connection creates no sidecars even for a WAL-header database; the
sidecar assertions after the call prove it.
"""
db_path = tmp_path / "nestquest.db"
# WAL fixture: created via the wrapper (journal_mode=WAL persists).
_valid_database(db_path)
data = bytearray(db_path.read_bytes())
# Corrupt the WAL header flag region: bytes 18/19 hold the
# read/write version numbers (2 = WAL). Flip them to garbage so
# the file claims a broken journal mode.
assert len(data) > 64
data[18] = 0x99
data[19] = 0x99
db_path.write_bytes(bytes(data))
corrupt_bytes = db_path.read_bytes()
digest = hashlib.sha256(corrupt_bytes).hexdigest()
with pytest.raises(ConfigEntryNotReady):
_run(_async_open_database(_make_hass_mock(), db_path))
# Main file byte-identical; no -shm appeared from the probe.
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
assert not (tmp_path / "nestquest.db-shm").exists()
def test_open_failure_closes_connection_when_opened(
tmp_path, monkeypatch
) -> None:
"""A failure DURING MIGRATIONS closes the opened connection; a
failure inside open() itself has nothing to close (the wrapper
never adopted a connection), so close must not be called there.
"""
db_path = tmp_path / "nestquest.db"
_truncated_database(db_path)
close_calls: list[bool] = []
original_close = NestQuestDatabase.close
async def _spy_close(self):
close_calls.append(True)
return await original_close(self)
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
with pytest.raises(ConfigEntryNotReady):
_run(_async_open_database(_make_hass_mock(), db_path))
# Corruption hit at PRAGMA time inside open(): no connection was
# adopted, so there is nothing to close and close() must NOT have
# been called (calling close on a never-opened wrapper would be
# harmless but the helper deliberately skips it).
assert close_calls == []
# Contrast: a failure DURING migrations (file opens fine) closes.
close_calls.clear()
valid_path = tmp_path / "migrations-fail.db"
_valid_database(valid_path)
# _valid_database's own close went through the spy; only the
# failure path's close counts from here.
close_calls.clear()
from custom_components.nestquest import migrations as migrations_mod
from custom_components.nestquest.db import NestQuestDatabase as _DB
async def _failing(database, migrations=None):
raise sqlite3.DatabaseError("database disk image is malformed")
monkeypatch.setattr(migrations_mod, "apply_migrations", _failing)
monkeypatch.setattr(
"custom_components.nestquest.apply_migrations", _failing
)
with pytest.raises(ConfigEntryNotReady) as excinfo:
_run(_async_open_database(_make_hass_mock(), valid_path))
# Exactly one close: the migrations-failure cleanup path. (The
# spy also sees the wrapper's own idempotent close if it were
# double-called — it must not be.)
assert close_calls == [True], (
"an open connection must be closed before the error propagates"
)
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
# ---------------------------------------------------------------------------
# Startup through async_setup_entry (the real wiring)
# ---------------------------------------------------------------------------
def _make_setup(hass, registry):
def _setup(entry):
return async_setup_entry(hass, entry)
return _setup
async def _setup_entry(hass, entry, registry) -> object:
entry = _wire(entry, registry)
await async_setup_entry(hass, entry)
return entry
async def test_setup_with_corrupt_file_raises_not_ready(
hass, make_entry, monkeypatch, tmp_path, caplog
) -> None:
from custom_components.nestquest.store import async_get_db_path
db_path = await async_get_db_path(hass)
await _avalid_database(db_path)
data = db_path.read_bytes()
assert len(data) > 64
db_path.write_bytes(data[: len(data) // 2])
corrupt_bytes = db_path.read_bytes()
entry = make_entry()
with pytest.raises(ConfigEntryNotReady):
await _setup_entry(hass, entry, hass.registry)
# File untouched, no runtime data stored.
assert db_path.read_bytes() == corrupt_bytes
assert hass.data["nestquest"].get(entry.entry_id) is None
async def test_setup_with_missing_file_creates_and_loads(
hass, make_entry
) -> None:
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
row = await database.fetch_one(
f"SELECT version FROM {VERSION_TABLE}"
)
assert row == (len(MIGRATIONS),)
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_with_valid_file_preserves_rows(
hass, make_entry
) -> None:
entry_a = await _setup_entry(hass, make_entry(), hass.registry)
database_a = entry_a.runtime_data.database
await database_a.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-14T12:00:00+00:00"),
)
# Unload pops the runtime record AND closes the connection; a
# fresh setup for the same entry_id then reopens the file.
assert await async_unload_entry(hass, entry_a) is True
entry_b = await _setup_entry(hass, make_entry(), hass.registry)
database_b = entry_b.runtime_data.database
assert database_b is not database_a
try:
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
assert row == (1,)
finally:
await database_b.close()
assert await async_unload_entry(hass, entry_b) is True
# ---------------------------------------------------------------------------
# No code path deletes or overwrites an existing database file
# ---------------------------------------------------------------------------
def test_package_never_unlinks_or_truncates_db_files() -> None:
"""Guardrail: no code path may delete or overwrite an existing
database file. Scans the package for unlink/rmtree/write on the
DB filename constant or *.db patterns.
Recursive (``rglob``) so the bundled ``core/`` package (extracted
in task d0b691d8 / 510c1f78) is scanned alongside the integration's
own modules — a non-recursive ``glob`` skips ``core/`` and leaves
the guardrail vacuous against it.
"""
import re
from pathlib import Path
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
pattern = re.compile(
r"(unlink|rmtree|write_bytes|write_text|open\s*\(\s*[^)]*['\"]w)"
r"[^\n]*(nestquest\.db|\.db['\"])|"
r"(nestquest\.db|\.db['\"])[^\n]*"
r"(unlink|rmtree|write_bytes|write_text)",
re.IGNORECASE,
)
offenders: list[str] = []
for py in sorted(package.rglob("*.py")):
if pattern.search(py.read_text()):
offenders.append(py.name)
assert offenders == [], (
f"code paths that could delete/overwrite the database: {offenders}"
)
+215
View File
@@ -0,0 +1,215 @@
"""Tests for store.py of NestQuest."""
from __future__ import annotations
import ast
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from custom_components.nestquest import store
from custom_components.nestquest.const import SQLITE_DB_FILENAME
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass(config_dir: Path, execute: bool = True):
hass = MagicMock()
hass.config.path = MagicMock(return_value=str(config_dir / SQLITE_DB_FILENAME))
hass.async_add_executor_job = AsyncMock(
side_effect=(lambda fn, *a: fn(*a)) if execute else None
)
return hass
def test_returns_absolute_path_from_config_path(monkeypatch) -> None:
# Path resolution only: the parent-dir mkdir is a side effect of
# core.store and is exercised elsewhere; suppress it here so the
# absolute-path assertion can run against /config (non-writable).
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
db_path = _run(store.async_get_db_path(hass))
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
assert db_path == Path("/config") / SQLITE_DB_FILENAME
assert db_path.is_absolute()
def test_relative_config_path_is_normalized_to_absolute(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
hass = MagicMock()
hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME)
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
db_path = _run(store.async_get_db_path(hass))
assert db_path.is_absolute()
assert db_path.name == SQLITE_DB_FILENAME
assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME)
assert db_path.parent.is_dir()
assert (tmp_path / "relative-config").is_dir()
assert not db_path.exists()
def test_creates_missing_parent_directory(tmp_path) -> None:
nested = tmp_path / "homeassistant" / "config"
hass = _make_hass(nested)
db_path = _run(store.async_get_db_path(hass))
assert db_path.parent == nested
assert nested.is_dir()
assert db_path.parent.is_dir()
assert not db_path.exists()
def test_two_calls_return_same_path(tmp_path) -> None:
hass = _make_hass(tmp_path)
first = _run(store.async_get_db_path(hass))
second = _run(store.async_get_db_path(hass))
assert first == second
assert first.parent.is_dir()
assert not first.exists()
assert not second.exists()
def test_existing_directory_does_not_fail(tmp_path) -> None:
nested = tmp_path / "config"
nested.mkdir()
hass = _make_hass(nested)
db_path = _run(store.async_get_db_path(hass))
assert db_path == nested / SQLITE_DB_FILENAME
assert db_path.parent.is_dir()
assert not db_path.exists()
def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None:
"""The parent-directory mkdir runs off the event loop via asyncio.to_thread.
core.store is Home-Assistant-free, so it schedules the blocking
``mkdir`` through :func:`asyncio.to_thread` rather than
``hass.async_add_executor_job``; the integration shim delegates to it
unchanged. The intent is preserved: the mkdir still runs off the
event loop, with ``_ensure_dir`` on the parent directory, and the
directory exists afterwards.
"""
nested = tmp_path / "deep" / "nested"
hass = _make_hass(nested)
to_thread_calls: list = []
real_to_thread = asyncio.to_thread
def _trace_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread)
db_path = _run(store.async_get_db_path(hass))
assert nested.is_dir()
assert len(to_thread_calls) == 1, (
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
)
fn, fn_args, fn_kwargs = to_thread_calls[0]
assert callable(fn)
assert fn is store._ensure_dir
assert fn_args == (nested,)
assert fn_kwargs == {}
assert db_path.parent == nested
assert db_path.parent.is_dir()
assert not db_path.exists()
def test_executor_helper_creates_directory(tmp_path) -> None:
nested = tmp_path / "executor" / "made"
store._ensure_dir(nested)
assert nested.is_dir()
store._ensure_dir(nested)
assert nested.is_dir()
def test_no_sqlite3_import_outside_db_module() -> None:
"""sqlite3 is banned package-wide except two justified modules.
db.py is the connection wrapper (all statement execution lives
there). __init__.py imports sqlite3 ONLY for its exception types
to classify corruption on startup (done-condition: corrupt file ->
ConfigEntryNotReady); it never opens a connection or executes
SQL — that is asserted separately below.
"""
pkg_dir = Path(store.__file__).parent
py_files = list(pkg_dir.glob("*.py"))
assert py_files
allowed = {"db.py", "__init__.py"}
for py_file in py_files:
if py_file.name in allowed:
continue
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
assert alias.name != "sqlite3", (
f"Found sqlite3 import in {py_file.name}:{node.lineno}"
)
elif isinstance(node, ast.ImportFrom) and node.module == "sqlite3":
raise AssertionError(
f"Found sqlite3 import in {py_file.name}:{node.lineno}"
)
def test_init_module_never_opens_or_executes_sqlite() -> None:
"""The __init__.py sqlite3 usage is limited to (a) exception typing
for corruption classification and (b) the ONE read-only preflight
connection (mode=ro URI, SELECT-only) that detects corruption in an
existing file before the real read-write open. No read-write
connect, no cursor writes, no non-SELECT statements may appear.
"""
import re
from pathlib import Path
init_path = (
Path(store.__file__).parent / "__init__.py"
).read_text(encoding="utf-8")
# The preflight connect must be the URI read-only form, and only
# SELECT statements and the integrity_check PRAGMA may run there.
assert "sqlite3.connect(uri, uri=True)" in init_path
assert "?mode=ro" in init_path
assert 'PRAGMA integrity_check' in init_path
forbidden = re.compile(
r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)"
r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT\s|PRAGMA\s+integrity_check)"
r"|\bexecutemany\b|\bexecutescript\b",
re.IGNORECASE,
)
assert forbidden.search(init_path) is None, (
"__init__.py may use sqlite3 only for exception typing and the "
"read-only corruption preflight (SELECT + integrity_check)"
)
def test_no_file_creation_calls_in_store() -> None:
source = Path(store.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(store.__file__))
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "open":
raise AssertionError(
f"Found open() call in store.py:{node.lineno}"
)
if (
isinstance(func, ast.Attribute)
and func.attr == "touch"
):
raise AssertionError(
f"Found touch() call in store.py:{node.lineno}"
)
assert "touch(" not in source
assert "open(" not in source
def test_container_style_path_resolves(monkeypatch) -> None:
monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None)
hass = _make_hass(Path("/config"), execute=False)
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
def test_core_style_path_resolves(tmp_path) -> None:
config_dir = tmp_path / ".homeassistant"
hass = _make_hass(config_dir)
assert _run(store.async_get_db_path(hass)) == config_dir / SQLITE_DB_FILENAME
assert (config_dir / SQLITE_DB_FILENAME).parent.is_dir()
+259
View File
@@ -0,0 +1,259 @@
"""Tests for the nightly missed-quest sweep (Feature 11, task 1)."""
from __future__ import annotations
import datetime
import re
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry
from custom_components.nestquest.children import create_child, list_children
from custom_components.nestquest.completion import (
complete_instance,
uncomplete_instance,
)
from custom_components.nestquest.const import EVENT_QUEST_MISSED
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.dao_meta import MetaStateDao
from custom_components.nestquest.materialize import materialize
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import ScheduleRule
from custom_components.nestquest.sweep import (
SWEEP_WATERMARK_KEY,
run_missed_sweep,
)
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
async def _seed(hass, make_entry) -> tuple:
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
today = datetime.date.today()
await create_quest_definition(
database,
"Brush teeth",
ScheduleRule.from_dict(
{"rule_type": "daily", "start_date": today.isoformat()}
),
[ada.id, bo.id],
["morning"],
)
end = (today + datetime.timedelta(days=3)).isoformat()
await materialize(database, today.isoformat(), end, today=today)
return entry, ada, bo
async def test_sweep_fires_one_event_per_past_due_open_instance(
hass, make_entry
) -> None:
entry, ada, bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 2, "one per child for today's now-past-due instance"
events = hass.bus.fired(EVENT_QUEST_MISSED)
assert len(events) == 2
payload = events[0]
assert payload["child_name"] in ("Ada", "Bo")
assert payload["quest_title"] == "Brush teeth"
assert payload["window"] == "morning"
assert payload["due_date"] == today.isoformat()
assert payload["due_time"] is None
assert _TIMESTAMP.match(payload["occurred_at"])
assert {payload["child_id"] for payload in events} == {ada.id, bo.id}
# The sweep is read-only over the domain tables.
assert await _count_events(database) == 0
async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None:
entry, _ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
first = await run_missed_sweep(hass, database, today=tomorrow)
second = await run_missed_sweep(hass, database, today=tomorrow)
assert first == 2
assert second == 0, "the watermark makes a rerun fire nothing"
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == (
tomorrow.isoformat()
)
async def test_sweep_only_fires_new_instances_after_watermark(
hass, make_entry
) -> None:
entry, ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
day_after = today + datetime.timedelta(days=2)
# First sweep one day ahead: today's instances fire.
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 2
first_events = hass.bus.fired(EVENT_QUEST_MISSED)
assert all(
payload["due_date"] == today.isoformat()
for payload in first_events
)
# One more day passes: only tomorrow's instances fire now.
fired = await run_missed_sweep(hass, database, today=day_after)
assert fired == 2
all_events = hass.bus.fired(EVENT_QUEST_MISSED)
assert len(all_events) == 4
assert all(
payload["due_date"] == tomorrow.isoformat()
for payload in all_events[2:]
)
async def test_sweep_never_reports_completed_or_reversed_instances(
hass, make_entry
) -> None:
entry, ada, bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
instances_ada = await QuestInstancesDao(database).list_by_date_range(
ada.id, today.isoformat(), today.isoformat()
)
instances_bo = await QuestInstancesDao(database).list_by_date_range(
bo.id, today.isoformat(), today.isoformat()
)
# Ada completed hers; Bo completed his then reversed it.
await complete_instance(
database,
instances_ada[0].id,
actor_source="panel",
actor_child_id=ada.id,
)
await complete_instance(
database,
instances_bo[0].id,
actor_source="panel",
actor_child_id=bo.id,
)
await uncomplete_instance(
database,
instances_bo[0].id,
actor_source="user",
actor_user_id="admin-1",
)
fired = await run_missed_sweep(hass, database, today=tomorrow)
assert fired == 0, (
"no-completion-event rule excludes both the completed and the "
"reversed instance (append-only history keeps both)"
)
assert hass.bus.fired(EVENT_QUEST_MISSED) == []
async def test_sweep_skips_instance_completed_after_sweep(
hass, make_entry
) -> None:
"""A missed instance completed later is never re-announced; the
late completion still lands in the append-only log."""
entry, ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
today = datetime.date.today()
tomorrow = today + datetime.timedelta(days=1)
day3 = today + datetime.timedelta(days=2)
await run_missed_sweep(hass, database, today=tomorrow)
instance = (
await QuestInstancesDao(database).list_by_date_range(
ada.id, today.isoformat(), today.isoformat()
)
)[0]
await complete_instance(
database,
instance.id,
actor_source="panel",
actor_child_id=ada.id,
)
fired = await run_missed_sweep(hass, database, today=day3)
assert fired == 0 or all(
payload["instance_id"] != instance.id
for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:]
)
async def test_rollover_listener_runs_sweep_after_materialization(
hass, make_entry, monkeypatch
) -> None:
import custom_components.nestquest as nestquest
calls = []
async def _record_sweep(hass_arg, database, **kwargs):
calls.append("sweep")
monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep)
entry = wire_entry_to_registry(
make_entry(options={"day_rollover_time": "00:00"}), hass.registry
)
assert await async_setup_entry(hass, entry) is True
assert calls == ["sweep"], "startup runs the sweep once"
await hass.time_change.fire()
assert calls == ["sweep", "sweep"], "the rollover listener sweeps too"
async def test_v6_database_migrates_to_v7_meta_state(
hass, make_entry
) -> None:
"""A database stamped at version 6 gains the meta_state table and
version 7 on the next startup; existing rows are untouched."""
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
database = entry.runtime_data.database
version = await database.fetch_one(
"SELECT version FROM nestquest_schema_version"
)
assert version == (7,)
tables = await database.fetch_all(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name='nestquest_meta_state'"
)
assert tables, "migration 7 created the meta_state table"
async def _count_events(database) -> int:
rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events")
return rows[0][0]
async def test_overlapping_sweeps_never_double_announce(
hass, make_entry
) -> None:
"""A startup sweep and a rollover sweep racing on the same night
queue on the sweep lock: the winner announces, the loser re-reads
the watermark inside the lock and fires nothing."""
import asyncio
entry, _ada, _bo = await _seed(hass, make_entry)
database = entry.runtime_data.database
tomorrow = datetime.date.today() + datetime.timedelta(days=1)
first, second = await asyncio.gather(
run_missed_sweep(hass, database, today=tomorrow),
run_missed_sweep(hass, database, today=tomorrow),
)
assert first + second == 2, "each instance announced exactly once"
assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2
+355
View File
@@ -0,0 +1,355 @@
"""Timezone tests for the single HA-local date source in the generation path.
These pin the done-condition that every "today" read in the generation path
derives from ``hass.config.time_zone`` — not the host clock and not UTC —
and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the
no-past guard resolve the correct local dates around midnight and across the
daylight-saving transitions.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from conftest import freeze_nestquest_clock
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.core.settings import NestQuestSettings
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.quest_definitions import (
assign_child,
create_quest_definition,
edit_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
NOW = "2026-09-14T12:00:00+00:00"
NEW_YORK = "America/New_York"
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
return database
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _freeze_clock(
monkeypatch, instant: datetime.datetime
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
``(frozen, requested)`` return shape the timezone assertions read.
"""
return freeze_nestquest_clock(monkeypatch, instant)
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
"""Assert ``now`` was called with (only) the configured HA time zone.
This is what turns the DST tests into a real proof of the HA-local date
source: if the generation path regressed to ``now(timezone.utc)``, its
requested tz would be UTC and this fails.
"""
assert requested, "the generation path never called now(tz)"
assert all(tz == ZoneInfo(time_zone) for tz in requested)
def _tz_hass(time_zone: str):
"""A minimal hass exposing only the ``config.time_zone`` the walk reads."""
return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone))
async def _seed_daily(database, start_iso: str) -> int:
"""One always-present child + a daily definition starting ``start_iso``."""
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
return child.id
def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date:
return instant_utc.astimezone(ZoneInfo(time_zone)).date()
def _assert_contiguous_horizon(records, local_today: datetime.date) -> None:
"""The horizon is exactly today..today+horizon, no off-by-one, no gap."""
expected = {
(local_today + datetime.timedelta(days=offset)).isoformat()
for offset in range(DEFAULT_HORIZON_DAYS + 1)
}
assert {r.due_date for r in records} == expected
assert len(records) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_newyork_local_date_after_local_midnight(
tmp_path, monkeypatch
) -> None:
"""A run just after New York local midnight uses the local date.
The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so
04:05 UTC). The horizon must start on 2026-03-10 — the local calendar
date — not any host/UTC-derived value.
"""
instant = datetime.datetime(
2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK)
)
local_today = instant.date()
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-midnight.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_local_date_not_utc_when_host_ahead(
tmp_path, monkeypatch
) -> None:
"""The walk's "today" is HA-local, never the UTC/system calendar date.
FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but
New York local is still 2026-01-14 22:00 EST. The horizon must start on
2026-01-14 (local), proving the UTC date did not win.
"""
instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc)
local_today = _local_date(instant, NEW_YORK)
assert local_today == datetime.date(2026, 1, 14)
assert instant.date() == datetime.date(2026, 1, 15)
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-host-ahead.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_resolves_spring_forward_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-03-08 spring-forward, today and the horizon stay local.
Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to
2026-03-08 local — the lost hour must not skip or repeat a calendar day —
and the full horizon remains 15 contiguous days.
"""
zone = ZoneInfo(NEW_YORK)
before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST
after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT
assert before.date() == after.date() == datetime.date(2026, 3, 8)
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (before, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-spring-forward.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 3, 8)
def test_horizon_resolves_fall_back_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-11-01 fall-back, today and the horizon stay local.
The repeated 01:00-02:00 hour must not resolve to the wrong calendar
day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30
(``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01
— and both, along with 03:30 EST after the jump, materialize the same
15 contiguous-day horizon with no off-by-one.
"""
zone = ZoneInfo(NEW_YORK)
first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT
second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST
after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST
assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1)
assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (first, second, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-fall-back.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 11, 1)
def test_definition_business_regeneration_uses_pinned_today_not_host(
tmp_path, monkeypatch
) -> None:
"""``edit_quest_definition``/``assign_child`` anchor regeneration on
the caller-pinned HA-local ``today``, not the host clock.
The host clock (the ``_today`` fallback) is pinned one day AHEAD of the
HA-local ``today``; the guard would reject the local "today" as past
(and the horizon would skip it) if it consulted the host clock. Both
business operations must regenerate against the pinned date and keep
that first local day, proving the threaded ``today`` wins.
"""
import custom_components.nestquest.core.dao_instances as dao_instances_module
pinned = datetime.date(2026, 1, 14)
host_today = datetime.date(2026, 1, 15)
monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today)
async def _body(database):
children = ChildrenDao(database)
dao = QuestInstancesDao(database)
horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
a = await children.create("Ada", NOW)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()),
[a.id],
[("morning", "09:00")],
)
definition_id = created.definition.id
# edit anchors on pinned: the horizon starts at the local day the
# host clock calls "past", so its presence proves pinned won.
await edit_quest_definition(
database,
definition_id,
windows=[("morning", "10:15")],
today=pinned,
)
a_records = await dao.list_by_date_range(
a.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in a_records][0] == pinned.isoformat()
assert {r.due_time for r in a_records} == {"10:15"}
assert len(a_records) == DEFAULT_HORIZON_DAYS + 1
# assign anchors on pinned too: the new child's regeneration keeps
# the same pinned-local first day rather than skipping to the host day.
b = await children.create("Bo", NOW)
await assign_child(database, definition_id, b.id, today=pinned)
b_records = await dao.list_by_date_range(
b.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in b_records][0] == pinned.isoformat()
assert len(b_records) == DEFAULT_HORIZON_DAYS + 1
return definition_id
async def _main():
database = await _prepare(tmp_path / "tz-definition-regen.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) is not None