Remove the integration's database, migrations, DAOs and business modules (#172)
This commit was merged in pull request #172.
This commit is contained in:
@@ -1,23 +1,25 @@
|
|||||||
"""The NestQuest integration."""
|
"""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, and forwards the sensor and
|
||||||
|
binary_sensor platforms.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import inspect
|
import inspect
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.event import async_track_time_change
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_SNAPSHOT_STALENESS,
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_SNAPSHOT_STALENESS,
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
@@ -26,315 +28,34 @@ from .const import (
|
|||||||
LOGGER,
|
LOGGER,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .core.settings import NestQuestSettings
|
|
||||||
from .frontend import async_register_frontend
|
|
||||||
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
from .coordinator import NestQuestCoordinator, coordinator_client_from_entry
|
||||||
from .db import NestQuestDatabase, make_database
|
from .frontend import async_register_frontend
|
||||||
from .materialize import materialize as _materialize_run
|
|
||||||
from .migrations import apply_migrations
|
|
||||||
from .services import async_deregister_services, async_register_services
|
from .services import async_deregister_services, async_register_services
|
||||||
from .sse import NestQuestEventStream
|
from .sse import NestQuestEventStream
|
||||||
from .store import async_get_db_path
|
|
||||||
from .admin_allowlist import seed_setup_admin
|
|
||||||
from .sweep import run_missed_sweep
|
|
||||||
|
|
||||||
_LOGGER = LOGGER
|
_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
|
@dataclass
|
||||||
class NestQuestRuntimeData:
|
class NestQuestRuntimeData:
|
||||||
"""Runtime data stored on a NestQuest config entry."""
|
"""Runtime data stored on a NestQuest config entry."""
|
||||||
|
|
||||||
entry_id: str
|
entry_id: str
|
||||||
settings: NestQuestSettings
|
|
||||||
database: NestQuestDatabase
|
|
||||||
remove_update_listener: Callable[[], Any]
|
remove_update_listener: Callable[[], Any]
|
||||||
remove_time_change_listener: Callable[[], Any]
|
|
||||||
coordinator: Any = None
|
coordinator: Any = None
|
||||||
event_stream: NestQuestEventStream | None = None
|
event_stream: NestQuestEventStream | None = None
|
||||||
|
|
||||||
|
|
||||||
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
|
||||||
"""Return every Home Assistant owner-account user id.
|
|
||||||
|
|
||||||
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 inside the integration: the
|
|
||||||
startup backfill and the daily rollover listener both 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:
|
def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||||
"""Return a live runtime record with a connected database, or None.
|
"""Return a live runtime record with a coordinator, or None.
|
||||||
|
|
||||||
The domain-global service (the panel completion proxy) has no
|
The domain-global service (the panel completion proxy) has no
|
||||||
database of its own; it resolves whichever config entry's runtime
|
database of its own; it resolves whichever config entry's runtime
|
||||||
data is currently live at call time, so it cannot hold a stale
|
data is currently live at call time, so it cannot hold a stale
|
||||||
handle to a closed connection after another entry unloads.
|
handle to an unloaded entry's coordinator.
|
||||||
"""
|
"""
|
||||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||||
database = getattr(runtime_data, "database", None)
|
if getattr(runtime_data, "coordinator", None) is not None:
|
||||||
if database is not None and database.connected:
|
|
||||||
return runtime_data
|
return runtime_data
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -367,47 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Set up NestQuest from a config entry."""
|
"""Set up NestQuest from a config entry."""
|
||||||
await async_register_frontend(hass)
|
await async_register_frontend(hass)
|
||||||
hass.data.setdefault(DOMAIN, {})
|
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(
|
async def _async_update_listener(
|
||||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||||
@@ -415,80 +95,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
"""Reload the entry when its options change."""
|
"""Reload the entry when its options change."""
|
||||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
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_update_listener: Callable[[], Any] | None = None
|
||||||
remove_time_change_listener: Callable[[], Any] | None = None
|
|
||||||
coordinator: Any = None
|
coordinator: Any = None
|
||||||
event_stream: NestQuestEventStream | None = None
|
event_stream: NestQuestEventStream | None = None
|
||||||
try:
|
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_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.
|
|
||||||
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)
|
_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
|
# ONE API client for the entry, shared by the coordinator's
|
||||||
# snapshot polling and the SSE subscription below (the client
|
# snapshot polling, the SSE subscription, and the completion
|
||||||
# is stateless — one session, one connection pool).
|
# 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).
|
||||||
api_client = coordinator_client_from_entry(hass, entry)
|
api_client = coordinator_client_from_entry(hass, entry)
|
||||||
# The shared Feature 10 coordinator: one refresh cycle every
|
# The shared coordinator: one refresh cycle every entity reads
|
||||||
# entity reads from (CONF_UPDATE_INTERVAL seconds, default
|
# from (CONF_UPDATE_INTERVAL seconds, default five minutes).
|
||||||
# five minutes). Since Feature 18 it polls the API service's
|
# It polls the API service's panel snapshot; a failed poll
|
||||||
# panel snapshot instead of reading the local database; an
|
# keeps the entities rendering the last-good snapshot within
|
||||||
# entry with no panel token yet gets the typed-error stub
|
# CONF_SNAPSHOT_STALENESS, unavailable past it.
|
||||||
# (unavailable entities, never a setup crash). Created AFTER
|
|
||||||
# services so a failure below unwinds them through the except
|
|
||||||
# path's listener handling.
|
|
||||||
coordinator = NestQuestCoordinator(
|
coordinator = NestQuestCoordinator(
|
||||||
hass,
|
hass,
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
@@ -513,20 +136,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
event_stream.start()
|
event_stream.start()
|
||||||
runtime_data = NestQuestRuntimeData(
|
runtime_data = NestQuestRuntimeData(
|
||||||
entry_id=entry.entry_id,
|
entry_id=entry.entry_id,
|
||||||
settings=settings,
|
|
||||||
database=database,
|
|
||||||
remove_update_listener=remove_update_listener,
|
remove_update_listener=remove_update_listener,
|
||||||
remove_time_change_listener=remove_time_change_listener,
|
|
||||||
coordinator=coordinator,
|
coordinator=coordinator,
|
||||||
event_stream=event_stream,
|
event_stream=event_stream,
|
||||||
)
|
)
|
||||||
# The runtime record is registered BEFORE the first refresh:
|
# The runtime record is registered BEFORE the first refresh:
|
||||||
# the refresh path (and anything it calls) resolves the entry's
|
# the service proxy resolves the entry's runtime state through
|
||||||
# runtime state through hass.data, and it must find the live
|
# hass.data, and it must find the live coordinator there —
|
||||||
# database there — never a half-set-up entry. The platforms
|
# never a half-set-up entry. The platforms forward below reads
|
||||||
# forward below reads the coordinator off the entry
|
# the coordinator off the entry (the HA runtime-data contract);
|
||||||
# (the HA runtime-data contract); a forward failure below
|
# a forward failure below unwinds it through the except path.
|
||||||
# unwinds it through the except path.
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||||
entry.runtime_data = runtime_data
|
entry.runtime_data = runtime_data
|
||||||
# The first refresh polls the API snapshot; an API failure
|
# The first refresh polls the API snapshot; an API failure
|
||||||
@@ -546,35 +165,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
pass
|
pass
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
# A failure anywhere after the database is open must not leak the
|
# A failure anywhere after the listener was registered must not
|
||||||
# listeners already registered against that (now-closed) database:
|
# leak it (or the SSE task): unwind EVERYTHING acquired before
|
||||||
# unwind EVERY remover we acquired before the failure, keeping the
|
# the failure, keeping the original error as the one raised.
|
||||||
# original error as the one raised. Removal is best-effort — a
|
# Removal is best-effort — a failing step must not mask the
|
||||||
# failing remover must not mask the setup error that triggered it.
|
# 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:
|
if event_stream is not None:
|
||||||
try:
|
try:
|
||||||
await event_stream.stop()
|
await event_stream.stop()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
for remove_listener in (
|
if remove_update_listener is not None:
|
||||||
remove_time_change_listener,
|
|
||||||
remove_update_listener,
|
|
||||||
):
|
|
||||||
if remove_listener is None:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
result = remove_listener()
|
remove_update_listener()
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
# A coordinator created before the failure holds entity
|
# A coordinator created before the failure holds entity
|
||||||
# listeners pointed at the database being closed below —
|
# listeners pointed at the never-registered runtime — shut it
|
||||||
# shut it down FIRST so no scheduled refresh can ever run
|
# down so no scheduled refresh outlives the failed setup.
|
||||||
# against a closed connection.
|
|
||||||
if coordinator is not None:
|
if coordinator is not None:
|
||||||
try:
|
try:
|
||||||
await coordinator.async_shutdown()
|
await coordinator.async_shutdown()
|
||||||
@@ -582,7 +190,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
pass
|
pass
|
||||||
# A platform-forward failure reached this unwind AFTER
|
# A platform-forward failure reached this unwind AFTER
|
||||||
# runtime_data was assigned: clear it so no later unload path
|
# runtime_data was assigned: clear it so no later unload path
|
||||||
# closes the already-closed database through the stale record.
|
# re-invokes the already-consumed update-listener remover
|
||||||
|
# through the stale record.
|
||||||
if getattr(entry, "runtime_data", None) is not None:
|
if getattr(entry, "runtime_data", None) is not None:
|
||||||
entry.runtime_data = None
|
entry.runtime_data = None
|
||||||
# The runtime record was ALSO registered in hass.data before
|
# The runtime record was ALSO registered in hass.data before
|
||||||
@@ -591,10 +200,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
# (hass.data wins over entry.runtime_data there) and
|
# (hass.data wins over entry.runtime_data there) and
|
||||||
# re-invokes the already-consumed update-listener remover.
|
# re-invokes the already-consumed update-listener remover.
|
||||||
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
await database.close()
|
|
||||||
raise
|
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
|
# The runtime record was already registered before the first
|
||||||
# refresh; re-assert the (unchanged) record so the return path
|
# refresh; re-assert the (unchanged) record so the return path
|
||||||
# stays readable as one registration site.
|
# stays readable as one registration site.
|
||||||
@@ -612,17 +218,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
if runtime_data is None:
|
if runtime_data is None:
|
||||||
runtime_data = getattr(entry, "runtime_data", None)
|
runtime_data = getattr(entry, "runtime_data", None)
|
||||||
# Platforms unload FIRST (mirroring HA conventions): entity
|
# Platforms unload FIRST (mirroring HA conventions): entity
|
||||||
# listeners must not outlive the database and coordinator the
|
# listeners must not outlive the coordinator the teardown below
|
||||||
# teardown below closes.
|
# shuts down.
|
||||||
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
platforms_unloaded = await hass.config_entries.async_unload_platforms(
|
||||||
entry, PLATFORMS
|
entry, PLATFORMS
|
||||||
)
|
)
|
||||||
if platforms_unloaded is False and runtime_data is not None:
|
if platforms_unloaded is False and runtime_data is not None:
|
||||||
# Home Assistant KEPT one or more platforms loaded — their
|
# Home Assistant KEPT one or more platforms loaded — their
|
||||||
# entities still live and must keep their database. Restore
|
# entities still live and must keep their coordinator. Restore
|
||||||
# the runtime record popped above and report failure so HA
|
# the runtime record popped above and report failure so HA
|
||||||
# retries the unload later instead of leaving live entities
|
# retries the unload later instead of leaving live entities
|
||||||
# backed by a closed database.
|
# backed by a torn-down coordinator.
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data
|
||||||
return False
|
return False
|
||||||
unload_error: BaseException | None = None
|
unload_error: BaseException | None = None
|
||||||
@@ -634,43 +240,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
stop_stream = getattr(event_stream, "stop", None)
|
stop_stream = getattr(event_stream, "stop", None)
|
||||||
if stop_stream is not None:
|
if stop_stream is not None:
|
||||||
await stop_stream()
|
await stop_stream()
|
||||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
remove_update_listener = getattr(
|
||||||
remove_time_change_listener = getattr(
|
runtime_data, "remove_update_listener", None
|
||||||
runtime_data, "remove_time_change_listener", None
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for remove_listener in (
|
if remove_update_listener is not None:
|
||||||
remove_update_listener,
|
result = remove_update_listener()
|
||||||
remove_time_change_listener,
|
if inspect.isawaitable(result):
|
||||||
):
|
await result
|
||||||
if remove_listener is None:
|
except BaseException as err:
|
||||||
continue
|
# A failing remover must not skip the coordinator shutdown
|
||||||
try:
|
# below; the first error is retained and re-raised only
|
||||||
result = remove_listener()
|
# after the teardown ran.
|
||||||
if inspect.isawaitable(result):
|
if unload_error is None:
|
||||||
await result
|
unload_error = err
|
||||||
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:
|
finally:
|
||||||
# The coordinator's entity listeners must stop BEFORE the
|
# The coordinator's entity listeners must stop here: a
|
||||||
# database closes: a scheduled refresh starting during the
|
# scheduled refresh must never outlive the unload.
|
||||||
# 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)
|
coordinator = getattr(runtime_data, "coordinator", None)
|
||||||
if coordinator is not None:
|
if coordinator is not None:
|
||||||
shutdown = getattr(coordinator, "async_shutdown", None)
|
shutdown = getattr(coordinator, "async_shutdown", None)
|
||||||
if shutdown is not None:
|
if shutdown is not None:
|
||||||
await shutdown()
|
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
|
# Domain-global services are torn down only once the FINAL entry
|
||||||
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
# unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling
|
||||||
# entry is still loaded.
|
# entry is still loaded.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -7,7 +7,7 @@ from homeassistant import config_entries
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
from .const import CONF_ADMIN_USER_IDS, DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
@@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the user step of the config flow.
|
"""Handle the user step of the config flow.
|
||||||
|
|
||||||
The HA user completing the flow is persisted into the entry
|
The integration needs no configuration to start (the API
|
||||||
data as the initial admin allowlist copy: real HA does not
|
connection is set in the options flow), so the user input is
|
||||||
guarantee the flow context survives to setup, and entry data is
|
stored as-is. The former admin-allowlist copy is gone: with
|
||||||
the persisted record first setup seeds from — the owner is
|
the database removed the allowlist lives in the API service's
|
||||||
never locked out.
|
own store, not on the entry.
|
||||||
"""
|
"""
|
||||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||||
if self._async_in_progress() or self._async_current_entries():
|
if self._async_in_progress() or self._async_current_entries():
|
||||||
@@ -43,12 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(step_id="user")
|
return self.async_show_form(step_id="user")
|
||||||
|
|
||||||
data = dict(user_input)
|
return self.async_create_entry(title="NestQuest", 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)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""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 for direct callers — since Feature 18
|
|
||||||
the HA service surface no longer fires transition events itself (the
|
|
||||||
API service publishes them on its SSE stream and the integration's
|
|
||||||
subscription re-fires them) — 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,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
"""Options flow for NestQuest post-setup settings."""
|
"""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.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -6,52 +17,25 @@ from typing import Any
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
|
||||||
CONF_AFTERNOON_REMINDER_TIME,
|
|
||||||
CONF_API_BASE_URL,
|
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_IDLE_TIMEOUT,
|
||||||
CONF_PANEL_TOKEN,
|
CONF_PANEL_TOKEN,
|
||||||
CONF_SNAPSHOT_STALENESS,
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_API_BASE_URL,
|
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_IDLE_TIMEOUT,
|
||||||
DEFAULT_PANEL_TOKEN,
|
DEFAULT_PANEL_TOKEN,
|
||||||
DEFAULT_SNAPSHOT_STALENESS,
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DOMAIN,
|
|
||||||
MIN_UPDATE_INTERVAL,
|
MIN_UPDATE_INTERVAL,
|
||||||
TIME_PATTERN,
|
|
||||||
)
|
)
|
||||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
|
||||||
from .api_client import is_valid_base_url
|
from .api_client import is_valid_base_url
|
||||||
|
|
||||||
MIN_HORIZON_DAYS = 1
|
|
||||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
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(
|
def _async_validate(
|
||||||
user_input: dict[str, Any],
|
user_input: dict[str, Any],
|
||||||
current: dict[str, Any] | None = None,
|
current: dict[str, Any] | None = None,
|
||||||
@@ -66,18 +50,6 @@ def _async_validate(
|
|||||||
"""
|
"""
|
||||||
errors: dict[str, str] = {}
|
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)
|
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
||||||
if (
|
if (
|
||||||
isinstance(panel_idle_timeout, bool)
|
isinstance(panel_idle_timeout, bool)
|
||||||
@@ -125,41 +97,6 @@ def _async_validate(
|
|||||||
if staleness < effective_interval:
|
if staleness < effective_interval:
|
||||||
errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness"
|
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
|
# The panel-plane API connection (Feature 18) is OPTIONAL in raw
|
||||||
# submits the same way: a missing key keeps the current value. The
|
# 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
|
# base URL must be a full http(s) URL with a host — the same check
|
||||||
@@ -182,261 +119,78 @@ def _async_validate(
|
|||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _strict_bool(value: object) -> bool:
|
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||||
"""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.
|
"""Build the form schema, pre-filled with the current values.
|
||||||
|
|
||||||
The admin picker is a multi-select of EXISTING Home Assistant users
|
The API connection (Feature 18) carries the base URL the API client
|
||||||
(never a free-text field — the allowlist stores user IDs); its
|
talks to and the panel service token it presents as the Bearer
|
||||||
default is the current database allowlist, so an untouched submit
|
credential. Schema-validated submits always carry every field (the
|
||||||
re-saves exactly who is already allowed. The notification section
|
defaults pre-fill from the stored values); raw callers omitting one
|
||||||
(Feature 11) carries the household's notify target, the three
|
keep its stored value (see _async_validate).
|
||||||
automation times, and four enable toggles — defaults from
|
|
||||||
:mod:`~.const`, never a hard-coded personal target.
|
|
||||||
"""
|
"""
|
||||||
fields: dict[Any, Any] = {
|
return vol.Schema(
|
||||||
vol.Required(
|
{
|
||||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
vol.Required(
|
||||||
): int,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
vol.Required(
|
default=current[CONF_PANEL_IDLE_TIMEOUT],
|
||||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
): int,
|
||||||
): str,
|
vol.Required(
|
||||||
vol.Required(
|
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
||||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
): int,
|
||||||
): int,
|
# The last-good staleness threshold (Feature 18): how long
|
||||||
vol.Required(
|
# the coordinator keeps serving the cached snapshot after
|
||||||
CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL]
|
# the API stops answering.
|
||||||
): int,
|
vol.Required(
|
||||||
# The last-good staleness threshold (Feature 18): how long the
|
CONF_SNAPSHOT_STALENESS,
|
||||||
# coordinator keeps serving the cached snapshot after the API
|
default=current[CONF_SNAPSHOT_STALENESS],
|
||||||
# stops answering. Schema-validated submits always carry it
|
): int,
|
||||||
# (the default pre-fills from the stored value); raw callers
|
# Panel-plane API connection (Feature 18): the base URL the
|
||||||
# omitting it keep their stored value (see _async_validate).
|
# API client talks to and the panel service token it
|
||||||
vol.Required(
|
# presents as the Bearer credential.
|
||||||
CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS]
|
vol.Required(
|
||||||
): int,
|
CONF_API_BASE_URL, default=current[CONF_API_BASE_URL]
|
||||||
vol.Required(
|
): str,
|
||||||
CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET]
|
vol.Required(
|
||||||
): str,
|
CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN]
|
||||||
vol.Required(
|
): str,
|
||||||
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):
|
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||||
"""Handle the options flow for NestQuest."""
|
"""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(
|
async def async_step_init(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Manage the NestQuest options."""
|
"""Manage the NestQuest options."""
|
||||||
admin_choices = await self._async_admin_choices()
|
|
||||||
admin_default = await self._async_current_admin_ids()
|
|
||||||
if user_input is None:
|
if user_input is None:
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=_build_schema(
|
data_schema=_build_schema(self._current_values()),
|
||||||
self._current_values(),
|
|
||||||
admin_choices=admin_choices,
|
|
||||||
admin_default=admin_default,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
errors = _async_validate(user_input, self._current_values())
|
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:
|
if errors:
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=_build_schema(
|
data_schema=_build_schema(self._current_values()),
|
||||||
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,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply the picker's selection to the allowlist when the
|
# Every remaining key is stored the fall-back-to-current way, so
|
||||||
# runtime database is reachable; the selection is always
|
# raw submits that predate a section keep that section's stored
|
||||||
# stored on the entry as the disaster-recovery copy.
|
# values.
|
||||||
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()
|
current = self._current_values()
|
||||||
for notification_key in (
|
data: dict[str, Any] = {}
|
||||||
CONF_NOTIFY_TARGET,
|
for key in (
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
CONF_PANEL_IDLE_TIMEOUT,
|
||||||
CONF_AFTERNOON_REMINDER_TIME,
|
CONF_UPDATE_INTERVAL,
|
||||||
CONF_END_OF_DAY_REPORT_TIME,
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_MORNING_SUMMARY_ENABLED,
|
CONF_API_BASE_URL,
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
CONF_PANEL_TOKEN,
|
||||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
|
||||||
CONF_CELEBRATION_ENABLED,
|
|
||||||
):
|
):
|
||||||
data[notification_key] = user_input.get(
|
data[key] = user_input.get(key, current[key])
|
||||||
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)
|
return self.async_create_entry(title="NestQuest", data=data)
|
||||||
|
|
||||||
def _current_values(self) -> dict[str, Any]:
|
def _current_values(self) -> dict[str, Any]:
|
||||||
@@ -445,19 +199,9 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
entry_data = getattr(self.config_entry, "data", None) or {}
|
entry_data = getattr(self.config_entry, "data", None) or {}
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
for key, default in (
|
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_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||||
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
(CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL),
|
||||||
(CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS),
|
(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_API_BASE_URL, DEFAULT_API_BASE_URL),
|
||||||
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN),
|
||||||
):
|
):
|
||||||
@@ -467,4 +211,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
|||||||
values[key] = entry_data[key]
|
values[key] = entry_data[key]
|
||||||
else:
|
else:
|
||||||
values[key] = default
|
values[key] = default
|
||||||
return values
|
return values
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -54,7 +54,7 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime):
|
|||||||
runtime = find_runtime(hass)
|
runtime = find_runtime(hass)
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"NestQuest has no live database; cannot run this service"
|
"NestQuest is not loaded; cannot run this service"
|
||||||
)
|
)
|
||||||
return runtime
|
return runtime
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""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))
|
|
||||||
@@ -18,25 +18,19 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NestQuest Settings",
|
"title": "NestQuest Settings",
|
||||||
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
"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": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"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_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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
"""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,25 +18,19 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "NestQuest Settings",
|
"title": "NestQuest Settings",
|
||||||
"description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.",
|
"description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.",
|
||||||
"data": {
|
"data": {
|
||||||
"horizon_days": "Generation horizon (days)",
|
|
||||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
|
||||||
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
"panel_idle_timeout": "Panel idle timeout (seconds)",
|
||||||
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
"snapshot_staleness": "Last-good snapshot staleness (seconds)",
|
||||||
"api_base_url": "NestQuest API base URL (http://host:port)",
|
"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": {
|
"errors": {
|
||||||
"invalid": "Invalid value.",
|
"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_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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-185
@@ -22,7 +22,6 @@ pytest -q``); it is never meant to run against a real HA install.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
import sys
|
import sys
|
||||||
@@ -742,160 +741,92 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
class LazyLocalSnapshotClient:
|
class StubSnapshotClient:
|
||||||
"""A test client that serves the snapshot built LOCALLY.
|
"""A no-network stand-in for the API client's snapshot surface.
|
||||||
|
|
||||||
Feature 18 moved the coordinator's refresh onto the API service's
|
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
|
||||||
panel snapshot, but most integration tests still seed the local
|
payload dict or an exception per call, consumed in order — and
|
||||||
database directly (the DB removal is a later task). This stub
|
records every call. When the queue is exhausted it re-serves the
|
||||||
bridges the two: ``get_snapshot`` resolves the entry's runtime
|
last outcome, so setup's first refresh and a test's explicit
|
||||||
record (its database and settings) and runs the SAME pure core
|
refreshes poll the same scripted state. Register it (or any
|
||||||
builder the API route runs
|
scripted client) with :func:`set_coordinator_client` BEFORE setup.
|
||||||
(: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, hass, entry_id: str) -> None:
|
def __init__(self, *outcomes) -> None:
|
||||||
self._hass = hass
|
|
||||||
self._entry_id = entry_id
|
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
#: Every ``(instance_id, CompletionResult)`` this stand-in
|
self._outcomes = list(outcomes)
|
||||||
# produced, in call order — tests read ``appended`` /
|
self._last = outcomes[-1] if outcomes else None
|
||||||
# ``was_on_time`` off these to check the completion verdicts.
|
#: The SSE frames the stand-in "published" (the API service's
|
||||||
self.completions = []
|
# stream content), drained by :func:`refire_api_transitions`.
|
||||||
#: 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 = []
|
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):
|
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
|
self.calls += 1
|
||||||
runtime = self._live_runtime()
|
if not self._outcomes:
|
||||||
if runtime is None:
|
# Re-serve the last outcome: setup's first refresh and the
|
||||||
return {
|
# test's explicit pass both poll the same scripted state.
|
||||||
"today_iso": datetime.date.today().isoformat(),
|
outcome = self._last
|
||||||
"cycle_day": 0,
|
else:
|
||||||
"children": [],
|
outcome = self._outcomes.pop(0)
|
||||||
}
|
self._last = outcome
|
||||||
database = runtime.database
|
if isinstance(outcome, BaseException):
|
||||||
settings = getattr(runtime, "settings", None)
|
raise outcome
|
||||||
# The HA-LOCAL now, the same single clock read the production
|
return outcome
|
||||||
# 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
|
|
||||||
|
|
||||||
from custom_components.nestquest.completion import complete_instance
|
class ScriptedPanelClient(StubSnapshotClient):
|
||||||
from custom_components.nestquest.core import events as core_events
|
"""Snapshot + completion stand-in for the API service's panel plane.
|
||||||
|
|
||||||
result = await complete_instance(
|
Serves the scripted snapshot outcomes (like
|
||||||
self._live_database(),
|
:class:`StubSnapshotClient`), records ``complete_instance`` calls
|
||||||
instance_id,
|
the way the panel route receives them, optionally fails them the
|
||||||
actor_source="panel",
|
way the client types the failure, and carries the SSE frames the
|
||||||
actor_child_id=actor_child_id,
|
API "published" in ``published_frames`` for
|
||||||
)
|
:func:`refire_api_transitions` to deliver. Everything the
|
||||||
self.completions.append((instance_id, result))
|
integration reads over the wire, no network.
|
||||||
if result.appended:
|
"""
|
||||||
# The route publishes the transition the moment the core
|
|
||||||
# write lands (appended completions only), evaluating the
|
def __init__(self, *outcomes) -> None:
|
||||||
# day-complete rule against the API host's local date —
|
super().__init__(*outcomes)
|
||||||
# the frames queue here for the SSE stream to carry.
|
self.complete_calls: list[tuple[int, int]] = []
|
||||||
event_type, payload = await core_events.build_quest_completed_event(
|
self.published_frames: list[tuple[str, dict]] = []
|
||||||
self._live_database(),
|
#: When set, the next ``complete_instance`` raises this instead
|
||||||
instance_id,
|
# of succeeding (a typed NestQuestApiError in production).
|
||||||
was_on_time=result.was_on_time,
|
self.fail_complete_with: BaseException | None = None
|
||||||
)
|
|
||||||
self.published_frames.append((event_type, payload))
|
async def complete_instance(
|
||||||
now = datetime.datetime.now().astimezone()
|
self, instance_id: int, actor_child_id: int
|
||||||
day_complete = await core_events.build_child_day_complete_event(
|
) -> dict:
|
||||||
self._live_database(), payload, today=now.date()
|
self.complete_calls.append((instance_id, actor_child_id))
|
||||||
)
|
if self.fail_complete_with is not None:
|
||||||
if day_complete is not None:
|
raise self.fail_complete_with
|
||||||
self.published_frames.append(day_complete)
|
return {"status": "done"}
|
||||||
return {"status": str(result)}
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def coordinator_serves_local_snapshot():
|
def coordinator_client_from_test_override():
|
||||||
"""Default every test entry's coordinator to the local-snapshot client.
|
"""Resolve every entry's coordinator client through the test seam.
|
||||||
|
|
||||||
The integration's setup builds its API client through
|
The integration's setup builds its API client through
|
||||||
``coordinator_client_from_entry``; the fixture swaps that factory
|
``coordinator_client_from_entry``; this fixture swaps that factory
|
||||||
for a test one handing out :class:`LazyLocalSnapshotClient` (so
|
for a test one handing out the client registered with
|
||||||
DB-seeded tests exercise the coordinator's API path against local
|
:func:`set_coordinator_client`, or — for an entry with no scripted
|
||||||
data). Tests that script their own client register it with
|
client — a stub serving an EMPTY but valid household snapshot (the
|
||||||
:func:`set_coordinator_client`, which the factory honours first.
|
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).
|
||||||
"""
|
"""
|
||||||
|
import datetime
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
import custom_components.nestquest as nq_mod
|
import custom_components.nestquest as nq_mod
|
||||||
import custom_components.nestquest.coordinator as coordinator_module
|
import custom_components.nestquest.coordinator as coordinator_module
|
||||||
|
|
||||||
@@ -907,9 +838,13 @@ def coordinator_serves_local_snapshot():
|
|||||||
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id)
|
||||||
if override is not None:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
return LazyLocalSnapshotClient(hass, entry.entry_id)
|
return StubSnapshotClient(
|
||||||
|
{
|
||||||
import unittest.mock as mock
|
"today_iso": datetime.date.today().isoformat(),
|
||||||
|
"cycle_day": 0,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
||||||
patcher = mock.patch.object(
|
patcher = mock.patch.object(
|
||||||
@@ -921,14 +856,6 @@ def coordinator_serves_local_snapshot():
|
|||||||
_COORDINATOR_CLIENT_OVERRIDES.clear()
|
_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):
|
async def refire_api_transitions(hass, entry, client):
|
||||||
"""Deliver the SSE frames the stand-in client has published so far
|
"""Deliver the SSE frames the stand-in client has published so far
|
||||||
through the integration's SSE subscription — the production event
|
through the integration's SSE subscription — the production event
|
||||||
@@ -1205,42 +1132,6 @@ def make_config_flow_entry():
|
|||||||
return _factory
|
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).
|
# Admin-plane API fixtures (shared by the admin-plane test modules).
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
|
"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
from custom_components.nestquest.core.admin_allowlist import (
|
||||||
from custom_components.nestquest.admin_allowlist import (
|
|
||||||
list_admin_ids,
|
list_admin_ids,
|
||||||
seed_setup_admin,
|
seed_setup_admin,
|
||||||
set_admin_ids,
|
set_admin_ids,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import AdminUsersDao
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -193,165 +190,6 @@ 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:
|
def test_seed_owner_ids_argument(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
# Malformed owner lists are rejected like any other candidate —
|
# Malformed owner lists are rejected like any other candidate —
|
||||||
@@ -377,54 +215,13 @@ def test_seed_owner_ids_argument(tmp_path) -> None:
|
|||||||
_with_db(tmp_path, "seed-owner-arg.db")(_body)
|
_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)
|
# is_admin: the fail-closed resolver (Feature 09's verdict function)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_is_admin_verdict_matrix(tmp_path) -> None:
|
def test_is_admin_verdict_matrix(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.admin_allowlist import is_admin
|
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||||
|
|
||||||
await set_admin_ids(database, ["user-owner"])
|
await set_admin_ids(database, ["user-owner"])
|
||||||
assert await is_admin(database, "user-owner") is True
|
assert await is_admin(database, "user-owner") is True
|
||||||
@@ -447,7 +244,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None:
|
|||||||
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
|
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
|
||||||
"""An empty allowlist returns False for EVERYONE, not True."""
|
"""An empty allowlist returns False for EVERYONE, not True."""
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.admin_allowlist import is_admin
|
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||||
|
|
||||||
for candidate in ("user-owner", "", None, 1):
|
for candidate in ("user-owner", "", None, 1):
|
||||||
assert await is_admin(database, candidate) is False
|
assert await is_admin(database, candidate) is False
|
||||||
@@ -460,7 +257,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 refusal keeps the verdict sound: a refused removal leaves
|
||||||
the admin in place, so is_admin still resolves True."""
|
the admin in place, so is_admin still resolves True."""
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.admin_allowlist import is_admin
|
from custom_components.nestquest.core.admin_allowlist import is_admin
|
||||||
|
|
||||||
await set_admin_ids(database, ["user-owner"])
|
await set_admin_ids(database, ["user-owner"])
|
||||||
with pytest.raises(ValueError, match="at least one admin"):
|
with pytest.raises(ValueError, match="at least one admin"):
|
||||||
|
|||||||
+131
-120
@@ -1,50 +1,85 @@
|
|||||||
"""Tests for the per-child binary sensors (Feature 10, task 4)."""
|
"""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.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
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
|
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.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):
|
def _binary(hass, child_id: int, kind: str):
|
||||||
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"]
|
||||||
|
|
||||||
|
|
||||||
async def _setup_entry(hass, make_entry):
|
def _child(
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
child_id: int,
|
||||||
assert await async_setup_entry(hass, entry) is True
|
name: str,
|
||||||
return entry
|
*,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _seed_two_children(database) -> tuple:
|
def _instance(instance_id: int, child_id: int, *, state="open") -> dict:
|
||||||
ada = await create_child(database, "Ada")
|
return {
|
||||||
bo = await create_child(database, "Bo")
|
"id": instance_id,
|
||||||
today = datetime.date.today()
|
"definition_id": 3,
|
||||||
await create_quest_definition(
|
"child_id": child_id,
|
||||||
database,
|
"title": "Brush teeth",
|
||||||
"Brush teeth",
|
"icon": "🦷",
|
||||||
ScheduleRule.from_dict(
|
"window": "morning",
|
||||||
{"rule_type": "daily", "start_date": today.isoformat()}
|
"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"}
|
||||||
),
|
),
|
||||||
[ada.id, bo.id],
|
hass.registry,
|
||||||
["morning"],
|
|
||||||
)
|
)
|
||||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||||
await materialize(database, today.isoformat(), end, today=today)
|
assert await async_setup_entry(hass, entry) is True
|
||||||
return ada, bo
|
return entry, entry.runtime_data.coordinator
|
||||||
|
|
||||||
|
|
||||||
async def test_all_done_off_with_open_quests_and_zero_quests(
|
async def test_all_done_off_with_open_quests_and_zero_quests(
|
||||||
@@ -52,88 +87,76 @@ async def test_all_done_off_with_open_quests_and_zero_quests(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
"""all_done is OFF while quests are owed AND on a zero-quest day;
|
||||||
a child with no quests never fires the celebration automation."""
|
a child with no quests never fires the celebration automation."""
|
||||||
entry = await _setup_entry(hass, make_entry)
|
_entry, coordinator = await _setup_entry(
|
||||||
coordinator = entry.runtime_data.coordinator
|
hass,
|
||||||
database = entry.runtime_data.database
|
make_entry,
|
||||||
ada, _bo = await _seed_two_children(database)
|
_payload(
|
||||||
await create_child(database, "Cory") # zero quests
|
[
|
||||||
await coordinator.async_refresh()
|
_child(ADA, "Ada", instances=[_instance(7, ADA)]),
|
||||||
|
_child(BO, "Bo", instances=[_instance(8, BO)]),
|
||||||
|
_child(CORY, "Cory"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
assert _binary(hass, ada.id, "all_done").is_on is False
|
assert _binary(hass, ADA, "all_done").is_on is False
|
||||||
cory = [
|
assert _binary(hass, BO, "all_done").is_on is False
|
||||||
child for child in coordinator.data.children
|
assert _binary(hass, CORY, "all_done").is_on is False
|
||||||
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:
|
async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None:
|
||||||
entry = await _setup_entry(hass, make_entry)
|
_entry, coordinator = await _setup_entry(
|
||||||
coordinator = entry.runtime_data.coordinator
|
hass,
|
||||||
database = entry.runtime_data.database
|
make_entry,
|
||||||
ada, _bo = await _seed_two_children(database)
|
_payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]),
|
||||||
await coordinator.async_refresh()
|
_payload(
|
||||||
snapshot_ada = [
|
[
|
||||||
child for child in coordinator.data.children
|
_child(
|
||||||
if child.child_id == ada.id
|
ADA,
|
||||||
][0]
|
"Ada",
|
||||||
instance_id = snapshot_ada.instances[0].instance_id
|
instances=[_instance(7, ADA, state="completed")],
|
||||||
await complete_instance(
|
)
|
||||||
database,
|
]
|
||||||
instance_id,
|
),
|
||||||
actor_source="panel",
|
|
||||||
actor_child_id=ada.id,
|
|
||||||
)
|
)
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
assert _binary(hass, ada.id, "all_done").is_on is True
|
assert _binary(hass, ADA, "all_done").is_on is True
|
||||||
|
|
||||||
|
|
||||||
async def test_present_today_on_without_schedule_off_when_absent(
|
async def test_present_today_on_without_schedule_off_when_absent(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""No schedule means present every day; an all-absent pattern
|
"""Presence is resolved by the API's presence engine: present=True
|
||||||
today reports OFF."""
|
renders ON, present=False renders OFF."""
|
||||||
entry = await _setup_entry(hass, make_entry)
|
_entry, _coordinator = await _setup_entry(
|
||||||
coordinator = entry.runtime_data.coordinator
|
hass,
|
||||||
database = entry.runtime_data.database
|
make_entry,
|
||||||
ada, _bo = await _seed_two_children(database)
|
_payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]),
|
||||||
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.id, "present_today").is_on is True
|
assert _binary(hass, ADA, "present_today").is_on is True
|
||||||
assert _binary(hass, cory.child_id, "present_today").is_on is False
|
assert _binary(hass, CORY, "present_today").is_on is False
|
||||||
|
|
||||||
|
|
||||||
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None:
|
||||||
entry = await _setup_entry(hass, make_entry)
|
_entry, _coordinator = await _setup_entry(
|
||||||
coordinator = entry.runtime_data.coordinator
|
hass, make_entry, _payload([_child(ADA, "Ada")])
|
||||||
database = entry.runtime_data.database
|
)
|
||||||
ada, _bo = await _seed_two_children(database)
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
|
|
||||||
all_done = _binary(hass, ada.id, "all_done")
|
all_done = _binary(hass, ADA, "all_done")
|
||||||
present = _binary(hass, ada.id, "present_today")
|
present = _binary(hass, ADA, "present_today")
|
||||||
assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done"
|
assert all_done.unique_id == f"nestquest_child_{ADA}_all_done"
|
||||||
assert present.unique_id == f"nestquest_child_{ada.id}_present_today"
|
assert present.unique_id == f"nestquest_child_{ADA}_present_today"
|
||||||
assert all_done.name == "NestQuest Ada all done"
|
assert all_done.name == "NestQuest Ada all done"
|
||||||
assert present.name == "NestQuest Ada present today"
|
assert present.name == "NestQuest Ada present today"
|
||||||
for entity in (all_done, present):
|
for entity in (all_done, present):
|
||||||
assert entity.device_info.name == "NestQuest Ada"
|
assert entity.device_info.name == "NestQuest Ada"
|
||||||
assert all_done.extra_state_attributes == {
|
assert all_done.extra_state_attributes == {
|
||||||
"child_id": ada.id,
|
"child_id": ADA,
|
||||||
"child_name": "Ada",
|
"child_name": "Ada",
|
||||||
}
|
}
|
||||||
assert present.extra_state_attributes == {
|
assert present.extra_state_attributes == {
|
||||||
"child_id": ada.id,
|
"child_id": ADA,
|
||||||
"child_name": "Ada",
|
"child_name": "Ada",
|
||||||
"next_present": None,
|
"next_present": None,
|
||||||
}
|
}
|
||||||
@@ -145,40 +168,28 @@ async def test_present_today_carries_next_present_when_away(
|
|||||||
"""A child away today carries the ISO date of their next present
|
"""A child away today carries the ISO date of their next present
|
||||||
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
day (the panel's away plate renders ``Returns <weekday>, <Mon D>``
|
||||||
from it); an always-absent pattern carries ``None``."""
|
from it); an always-absent pattern carries ``None``."""
|
||||||
entry = await _setup_entry(hass, make_entry)
|
_entry, _coordinator = await _setup_entry(
|
||||||
coordinator = entry.runtime_data.coordinator
|
hass,
|
||||||
database = entry.runtime_data.database
|
make_entry,
|
||||||
ada, _bo = await _seed_two_children(database)
|
_payload(
|
||||||
await create_child(database, "Cory")
|
[
|
||||||
today = datetime.date.today()
|
_child(
|
||||||
pattern = ",".join(
|
ADA,
|
||||||
str(day) for day in range(7) if day != today.weekday()
|
"Ada",
|
||||||
|
present=False,
|
||||||
|
next_present="2026-09-24",
|
||||||
|
),
|
||||||
|
_child(CORY, "Cory", present=False, next_present=None),
|
||||||
|
]
|
||||||
|
),
|
||||||
)
|
)
|
||||||
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.id, "present_today").is_on is False
|
assert _binary(hass, ADA, "present_today").is_on is False
|
||||||
assert _binary(hass, ada.id, "present_today").extra_state_attributes[
|
assert _binary(hass, ADA, "present_today").extra_state_attributes[
|
||||||
"next_present"
|
"next_present"
|
||||||
] == (today + datetime.timedelta(days=1)).isoformat()
|
] == "2026-09-24"
|
||||||
|
|
||||||
cory = [
|
assert _binary(hass, CORY, "present_today").is_on is False
|
||||||
child for child in coordinator.data.children
|
assert _binary(hass, CORY, "present_today").extra_state_attributes[
|
||||||
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"
|
"next_present"
|
||||||
] is None
|
] 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,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.children import (
|
from custom_components.nestquest.core.children import (
|
||||||
create_child,
|
create_child,
|
||||||
edit_child,
|
edit_child,
|
||||||
list_children,
|
list_children,
|
||||||
reorder_children,
|
reorder_children,
|
||||||
set_child_active,
|
set_child_active,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events(
|
|||||||
async def _body(database):
|
async def _body(database):
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
)
|
)
|
||||||
@@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None:
|
|||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
|
|
||||||
methods = {
|
methods = {
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -5,117 +5,69 @@ complete route through the entry's ONE shared API client (the same
|
|||||||
instance the coordinator polls and the SSE subscription reads), with
|
instance the coordinator polls and the SSE subscription reads), with
|
||||||
exactly the payload contract the panel cards send (``instance_id``,
|
exactly the payload contract the panel cards send (``instance_id``,
|
||||||
``actor=panel``, ``actor_child_id``). The service performs NO local
|
``actor=panel``, ``actor_child_id``). The service performs NO local
|
||||||
database completion and fires NO local bus event — the API service
|
completion and fires NO local bus event — the API service emits the
|
||||||
emits the transition on its SSE stream and the integration's
|
transition on its SSE stream and the integration's subscription
|
||||||
subscription re-fires it (firing it locally too would double-fire) —
|
re-fires it (firing it locally too would double-fire) — and it
|
||||||
and it refreshes the coordinator after a successful call so the
|
refreshes the coordinator after a successful call so the sensors
|
||||||
sensors reflect the completion immediately. Every failure (a typed
|
reflect the completion immediately. Every failure (a typed
|
||||||
:class:`NestQuestApiError`, an unconfigured client, a non-panel
|
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
|
||||||
actor) surfaces as a :class:`HomeAssistantError`, token-free.
|
surfaces as a :class:`HomeAssistantError`, token-free.
|
||||||
|
|
||||||
The scripted clients here stand in for the API service: recording and
|
The scripted clients here stand in for the API service: recording and
|
||||||
failing ``complete_instance`` implementations, no network, no aiohttp.
|
failing ``complete_instance`` implementations, no network, no aiohttp.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from conftest import (
|
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||||
LazyLocalSnapshotClient,
|
|
||||||
set_coordinator_client,
|
|
||||||
wire_entry_to_registry,
|
|
||||||
)
|
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.api_client import NestQuestApiError
|
from custom_components.nestquest.api_client import NestQuestApiError
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_PANEL_TOKEN,
|
CONF_PANEL_TOKEN,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_CHILD_DAY_COMPLETE,
|
EVENT_CHILD_DAY_COMPLETE,
|
||||||
EVENT_QUEST_COMPLETED,
|
EVENT_QUEST_COMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
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(LazyLocalSnapshotClient):
|
class _RecordingApiClient(ScriptedPanelClient):
|
||||||
"""Snapshot-only stand-in that RECORDS completion calls.
|
"""Snapshot-only stand-in that RECORDS completion calls.
|
||||||
|
|
||||||
``complete_instance`` deliberately performs NO database write: the
|
``complete_instance`` deliberately performs NO local write: the
|
||||||
API service is remote, and the assertions below must be able to
|
API service is remote, and the assertions below must be able to
|
||||||
prove the service handler itself completed nothing locally.
|
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):
|
class _FailingApiClient(_RecordingApiClient):
|
||||||
"""A stand-in whose completion fails the way the client types it."""
|
"""A stand-in whose completion fails the way the client types it."""
|
||||||
|
|
||||||
def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None:
|
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
|
||||||
super().__init__(hass, entry_id)
|
super().__init__(*outcomes)
|
||||||
self._error = error
|
self.fail_complete_with = 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
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup_seeded_entry(hass, make_entry, client):
|
def _empty_snapshot() -> dict:
|
||||||
"""Wire, script, and set up an entry with one child's daily quest."""
|
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."""
|
||||||
entry = wire_entry_to_registry(
|
entry = wire_entry_to_registry(
|
||||||
make_entry(
|
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
|
||||||
data={
|
|
||||||
CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]],
|
|
||||||
CONF_PANEL_TOKEN: "secret-panel-token",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
hass.registry,
|
hass.registry,
|
||||||
)
|
)
|
||||||
set_coordinator_client(entry, client)
|
set_coordinator_client(entry, client)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
database = entry.runtime_data.database
|
return entry
|
||||||
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]:
|
def _record_refreshes(entry) -> list[bool]:
|
||||||
@@ -133,11 +85,10 @@ async def test_complete_quest_reaches_the_shared_api_client(
|
|||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A panel tap calls ``complete_instance`` on the entry's shared
|
"""A panel tap calls ``complete_instance`` on the entry's shared
|
||||||
client with the tapped child, completes NOTHING locally, fires NO
|
client with the tapped child, fires NO local event, and refreshes
|
||||||
local event, and refreshes the coordinator immediately."""
|
the coordinator immediately."""
|
||||||
entry, client, child, instance = await _setup_seeded_entry(
|
client = _RecordingApiClient(_empty_snapshot())
|
||||||
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
entry = await _setup_entry(hass, make_entry, client)
|
||||||
)
|
|
||||||
assert entry.runtime_data.coordinator.api_client is client, (
|
assert entry.runtime_data.coordinator.api_client is client, (
|
||||||
"the service must proxy through the entry's ONE shared client"
|
"the service must proxy through the entry's ONE shared client"
|
||||||
)
|
)
|
||||||
@@ -147,21 +98,13 @@ async def test_complete_quest_reaches_the_shared_api_client(
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{
|
{
|
||||||
"instance_id": instance.id,
|
"instance_id": 7,
|
||||||
"actor": "panel",
|
"actor": "panel",
|
||||||
"actor_child_id": child.id,
|
"actor_child_id": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert client.complete_calls == [(instance.id, child.id)]
|
assert client.complete_calls == [(7, 1)]
|
||||||
# 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
|
# No local firing: the completed (and day-complete) events arrive
|
||||||
# over the SSE stream, re-fired by the subscription — never from
|
# over the SSE stream, re-fired by the subscription — never from
|
||||||
# this handler.
|
# this handler.
|
||||||
@@ -198,13 +141,10 @@ async def test_api_failure_surfaces_as_a_service_error(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A typed API failure (non-2xx or transport) raises
|
"""A typed API failure (non-2xx or transport) raises
|
||||||
``HomeAssistantError`` carrying the typed message — which never
|
``HomeAssistantError`` carrying the typed message — which never
|
||||||
includes the panel token — with nothing completed or fired
|
includes the panel token — with nothing fired locally, and no
|
||||||
locally, and no refresh for a mutation that never happened."""
|
refresh for a mutation that never happened."""
|
||||||
entry, client, child, instance = await _setup_seeded_entry(
|
client = _FailingApiClient(error, _empty_snapshot())
|
||||||
hass,
|
entry = await _setup_entry(hass, make_entry, client)
|
||||||
make_entry,
|
|
||||||
_FailingApiClient(hass, "test_entry", error),
|
|
||||||
)
|
|
||||||
refreshes = _record_refreshes(entry)
|
refreshes = _record_refreshes(entry)
|
||||||
|
|
||||||
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
||||||
@@ -212,22 +152,16 @@ async def test_api_failure_surfaces_as_a_service_error(
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{
|
{
|
||||||
"instance_id": instance.id,
|
"instance_id": 7,
|
||||||
"actor": "panel",
|
"actor": "panel",
|
||||||
"actor_child_id": child.id,
|
"actor_child_id": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# The typed error is chained, and the service error carries the
|
# The typed error is chained, and the service error carries the
|
||||||
# typed message only — never the panel token.
|
# typed message only — never the panel token.
|
||||||
assert excinfo.value.__cause__ is error
|
assert excinfo.value.__cause__ is error
|
||||||
assert "secret-panel-token" not in str(excinfo.value)
|
assert "secret-panel-token" not in str(excinfo.value)
|
||||||
assert client.complete_calls == [(instance.id, child.id)]
|
assert client.complete_calls == [(7, 1)]
|
||||||
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_QUEST_COMPLETED) == []
|
||||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||||
assert refreshes == []
|
assert refreshes == []
|
||||||
@@ -241,9 +175,7 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
|||||||
of reaching an attribute error."""
|
of reaching an attribute error."""
|
||||||
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
||||||
|
|
||||||
entry, _client, child, instance = await _setup_seeded_entry(
|
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
|
||||||
hass, make_entry, _UnconfiguredApiClient()
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
HomeAssistantError, match="NestQuest API is not configured"
|
HomeAssistantError, match="NestQuest API is not configured"
|
||||||
@@ -252,9 +184,9 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
|||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{
|
{
|
||||||
"instance_id": instance.id,
|
"instance_id": 7,
|
||||||
"actor": "panel",
|
"actor": "panel",
|
||||||
"actor_child_id": child.id,
|
"actor_child_id": 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,19 +197,18 @@ async def test_non_panel_actor_is_rejected_before_the_client(
|
|||||||
"""The API's panel route completes as the tapped child only: a
|
"""The API's panel route completes as the tapped child only: a
|
||||||
user-actor call fails fast with a clear error and never reaches
|
user-actor call fails fast with a clear error and never reaches
|
||||||
the client."""
|
the client."""
|
||||||
entry, client, child, instance = await _setup_seeded_entry(
|
client = _RecordingApiClient(_empty_snapshot())
|
||||||
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
await _setup_entry(hass, make_entry, client)
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
||||||
await hass.services.call(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{
|
{
|
||||||
"instance_id": instance.id,
|
"instance_id": 7,
|
||||||
"actor": "user",
|
"actor": "user",
|
||||||
"actor_user_id": ADMIN_CTX["user_id"],
|
"actor_user_id": "admin-1",
|
||||||
},
|
},
|
||||||
context=ADMIN_CTX,
|
context={"user_id": "admin-1"},
|
||||||
)
|
)
|
||||||
assert client.complete_calls == []
|
assert client.complete_calls == []
|
||||||
|
|||||||
@@ -8,27 +8,27 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.completion import (
|
from custom_components.nestquest.core.completion import (
|
||||||
append_event,
|
append_event,
|
||||||
complete_instance,
|
complete_instance,
|
||||||
instance_state,
|
instance_state,
|
||||||
list_missed_for_child,
|
list_missed_for_child,
|
||||||
uncomplete_instance,
|
uncomplete_instance,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
EVENT_COMPLETED,
|
EVENT_COMPLETED,
|
||||||
EVENT_UNCOMPLETED,
|
EVENT_UNCOMPLETED,
|
||||||
CompletionEventRecord,
|
CompletionEventRecord,
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
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:
|
def test_module_never_exposes_update_or_delete() -> None:
|
||||||
import custom_components.nestquest.completion as completion
|
import custom_components.nestquest.core.completion as completion
|
||||||
|
|
||||||
public = {
|
public = {
|
||||||
name
|
name
|
||||||
|
|||||||
@@ -156,23 +156,13 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
|||||||
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
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(
|
async def test_flow_without_context_user_keeps_data_untouched(
|
||||||
hass, make_flow
|
hass, make_flow
|
||||||
) -> None:
|
) -> 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)
|
flow = make_flow(hass)
|
||||||
result = await flow.async_step_user({"horizon_days": 7})
|
result = await flow.async_step_user({"update_interval": 60})
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["data"] == {"horizon_days": 7}
|
assert result["data"] == {"update_interval": 60}
|
||||||
|
|||||||
@@ -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.
|
test feeds the scanner each bypass shape and asserts it flags them.
|
||||||
- A settings object constructed with NON-default ``horizon_days`` and
|
- A settings object constructed with NON-default ``horizon_days`` and
|
||||||
``day_rollover_time`` drives a materialization whose resulting window
|
``day_rollover_time`` drives a materialization whose resulting window
|
||||||
matches the non-default horizon — via BOTH the core ``materialize``
|
matches the non-default horizon — via the core ``materialize`` path
|
||||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
(the integration path it used to share is gone with the integration's
|
||||||
``_register_day_rollover_listener``).
|
database).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import custom_components.nestquest.core as _core_mod
|
import custom_components.nestquest.core as _core_mod
|
||||||
from conftest import freeze_nestquest_clock
|
|
||||||
|
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||||
@@ -741,7 +740,7 @@ def test_settings_module_has_no_ha_imports() -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Materialization over a non-default horizon: core path + integration path.
|
# Materialization over a non-default horizon (core path).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -828,85 +827,3 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
|||||||
) == []
|
) == []
|
||||||
finally:
|
finally:
|
||||||
await database.close()
|
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()
|
|
||||||
|
|||||||
+30
-17
@@ -10,17 +10,22 @@ child's ``next_present``, a due-timed quest's ``overdue`` under a
|
|||||||
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload
|
||||||
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and
|
||||||
``missed`` omission) are all exercised as literals.
|
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import set_coordinator_client, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import create_child
|
from custom_components.nestquest.core.children import create_child
|
||||||
from custom_components.nestquest.completion import complete_instance
|
from custom_components.nestquest.core.completion import complete_instance
|
||||||
from custom_components.nestquest.core.snapshot import (
|
from custom_components.nestquest.core.snapshot import (
|
||||||
ChildDaySnapshot,
|
ChildDaySnapshot,
|
||||||
NestQuestSnapshot,
|
NestQuestSnapshot,
|
||||||
@@ -28,14 +33,17 @@ from custom_components.nestquest.core.snapshot import (
|
|||||||
build_snapshot,
|
build_snapshot,
|
||||||
instance_payload,
|
instance_payload,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
PresenceSchedulesDao,
|
PresenceSchedulesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.materialize import materialize
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.quest_definitions import create_quest_definition
|
from custom_components.nestquest.core.materialize import materialize
|
||||||
from custom_components.nestquest.recurrence import ScheduleRule
|
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
|
||||||
|
|
||||||
|
|
||||||
async def test_instance_payload_shape_and_missed_omission() -> None:
|
async def test_instance_payload_shape_and_missed_omission() -> None:
|
||||||
@@ -105,7 +113,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None:
|
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
|
||||||
"""The builder produces the documented shape as literals, pinned in time.
|
"""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,
|
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
|
||||||
@@ -117,11 +125,12 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
|||||||
scheduled active child is Ada, whose 2-week cycle anchored one day
|
scheduled active child is Ada, whose 2-week cycle anchored one day
|
||||||
before today puts ``cycle_day`` at 2.
|
before today puts ``cycle_day`` at 2.
|
||||||
"""
|
"""
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
# A standalone core database (the integration owns none): the
|
||||||
assert await async_setup_entry(hass, entry) is True
|
# builder and its seed run against it through the executor.
|
||||||
coordinator = entry.runtime_data.coordinator
|
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||||
database = entry.runtime_data.database
|
await database.open(tmp_path / "snapshot.db")
|
||||||
settings = entry.runtime_data.settings
|
await apply_migrations(database)
|
||||||
|
settings = NestQuestSettings()
|
||||||
|
|
||||||
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
# Single clock read for the seed/snapshot anchor (no midnight skew);
|
||||||
# the coordinator no longer owns a clock read (the API serves the
|
# the coordinator no longer owns a clock read (the API serves the
|
||||||
@@ -303,8 +312,6 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
|||||||
# The dataclasses are frozen, so == is a full deep compare — a
|
# The dataclasses are frozen, so == is a full deep compare — a
|
||||||
# reconstruction that dropped or reshaped a field would diverge
|
# reconstruction that dropped or reshaped a field would diverge
|
||||||
# here even though the literal assertions above still pass.
|
# here even though the literal assertions above still pass.
|
||||||
from conftest import set_coordinator_client
|
|
||||||
|
|
||||||
class _PinnedPayloadClient:
|
class _PinnedPayloadClient:
|
||||||
async def get_snapshot(self):
|
async def get_snapshot(self):
|
||||||
return {
|
return {
|
||||||
@@ -328,6 +335,12 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) ->
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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())
|
set_coordinator_client(entry, _PinnedPayloadClient())
|
||||||
await coordinator.async_refresh()
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
coordinator = entry.runtime_data.coordinator
|
||||||
assert coordinator.data == built
|
assert coordinator.data == built
|
||||||
|
await database.close()
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import sqlite3
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import (
|
from custom_components.nestquest.core.dao_children import (
|
||||||
AdminUserRecord,
|
AdminUserRecord,
|
||||||
AdminUsersDao,
|
AdminUsersDao,
|
||||||
ChildRecord,
|
ChildRecord,
|
||||||
ChildrenDao,
|
ChildrenDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
|
from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -504,9 +504,6 @@ 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_rules.py", # rules/definitions guard, own scope
|
||||||
"tests/test_dao_presence.py", # presence guard, own scope
|
"tests/test_dao_presence.py", # presence guard, own scope
|
||||||
"tests/test_dao_instances.py", # instances 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(
|
sql_pattern = re.compile(
|
||||||
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+"
|
||||||
@@ -558,32 +555,6 @@ def _strip_function_spans(text: str, names: set[str]) -> str:
|
|||||||
for number, line in enumerate(lines, start=1)
|
for number, line in enumerate(lines, start=1)
|
||||||
if number not in excluded
|
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:
|
def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None:
|
||||||
"""Compensating self-scan for the matrix-file exemption, structurally
|
"""Compensating self-scan for the matrix-file exemption, structurally
|
||||||
exact: parse the two sanctioned functions' ASTs and require that
|
exact: parse the two sanctioned functions' ASTs and require that
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import sqlite3
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
CompletionEventRecord,
|
CompletionEventRecord,
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstanceRecord,
|
QuestInstanceRecord,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
|
|||||||
@@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import (
|
from custom_components.nestquest.core.dao_children import (
|
||||||
AdminUsersDao,
|
AdminUsersDao,
|
||||||
ChildrenDao,
|
ChildrenDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
PresenceSchedulesDao,
|
PresenceSchedulesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import sqlite3
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverrideRecord,
|
PresenceOverrideRecord,
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
PresenceScheduleRecord,
|
PresenceScheduleRecord,
|
||||||
PresenceSchedulesDao,
|
PresenceSchedulesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
async def _main():
|
async def _main():
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
|
|
||||||
armed = {"active": False, "gated": False}
|
armed = {"active": False, "gated": False}
|
||||||
gate_open = asyncio_module.Event()
|
gate_open = asyncio_module.Event()
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import sqlite3
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
ScheduleRuleRecord,
|
ScheduleRuleRecord,
|
||||||
ScheduleRuleStorage,
|
ScheduleRuleStorage,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
@@ -17,9 +17,9 @@ from custom_components.nestquest.dao_rules import (
|
|||||||
schedule_rule_from_storage,
|
schedule_rule_from_storage,
|
||||||
schedule_rule_to_storage,
|
schedule_rule_to_storage,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
@@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
|||||||
# delete task has been scheduled, so the create holds
|
# delete task has been scheduled, so the create holds
|
||||||
# the connection lock while the delete queues behind
|
# the connection lock while the delete queues behind
|
||||||
# it — then the create proceeds and wins.
|
# it — then the create proceeds and wins.
|
||||||
import custom_components.nestquest.dao_rules as dao_rules
|
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||||
|
|
||||||
original_validate = (
|
original_validate = (
|
||||||
dao_rules.QuestDefinitionsDao._validate_rule_exists
|
dao_rules.QuestDefinitionsDao._validate_rule_exists
|
||||||
|
|||||||
+1
-66
@@ -14,7 +14,7 @@ import pytest
|
|||||||
from conftest import executor_for, make_hass
|
from conftest import executor_for, make_hass
|
||||||
|
|
||||||
import custom_components.nestquest.core.db as db_module
|
import custom_components.nestquest.core.db as db_module
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -933,68 +933,3 @@ 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 == {}
|
|
||||||
+121
-106
@@ -1,24 +1,16 @@
|
|||||||
"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8)."""
|
"""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.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
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 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 = (
|
PER_CHILD_KINDS = (
|
||||||
"quests_due_today",
|
"quests_due_today",
|
||||||
"quests_completed_today",
|
"quests_completed_today",
|
||||||
@@ -34,6 +26,78 @@ HOUSEHOLD_UNIQUE_IDS = (
|
|||||||
"nestquest_cycle_day",
|
"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:
|
async def _reload(hass, entry) -> None:
|
||||||
"""Unload then set up: the harness's restart/reload equivalent."""
|
"""Unload then set up: the harness's restart/reload equivalent."""
|
||||||
@@ -41,46 +105,36 @@ async def _reload(hass, entry) -> None:
|
|||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
|
|
||||||
|
|
||||||
def _child_unique_ids(child_id: int) -> set[str]:
|
async def _setup_with_household(hass, make_entry, *payloads):
|
||||||
return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS}
|
"""Set up the entry with a scripted, ordered snapshot history."""
|
||||||
|
entry = wire_entry_to_registry(
|
||||||
|
make_entry(
|
||||||
async def _setup_and_seed(hass, make_entry):
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||||
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],
|
hass.registry,
|
||||||
["morning"],
|
|
||||||
)
|
)
|
||||||
end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat()
|
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||||
await materialize(database, today.isoformat(), end, today=today)
|
assert await async_setup_entry(hass, entry) is True
|
||||||
await entry.runtime_data.coordinator.async_refresh()
|
return entry, entry.runtime_data.coordinator, client
|
||||||
return entry, ada, bo
|
|
||||||
|
|
||||||
|
|
||||||
async def test_deactivated_child_entities_removed_on_reload(
|
async def test_deactivated_child_entities_removed_on_reload(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
"""A child absent from the API's snapshot loses its entities on
|
||||||
assert _child_unique_ids(ada.id) <= set(hass.entities)
|
reload; the surviving child's entities are untouched."""
|
||||||
assert _child_unique_ids(bo.id) <= set(hass.entities)
|
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)
|
||||||
|
|
||||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
|
||||||
await _reload(hass, entry)
|
await _reload(hass, entry)
|
||||||
|
|
||||||
assert _child_unique_ids(ada.id) <= set(hass.entities), (
|
assert _child_unique_ids(ADA) <= set(hass.entities), (
|
||||||
"the surviving child's entities must be untouched"
|
"the surviving child's entities must be untouched"
|
||||||
)
|
)
|
||||||
assert not (_child_unique_ids(bo.id) & set(hass.entities)), (
|
assert not (_child_unique_ids(BO) & set(hass.entities)), (
|
||||||
"the deactivated child's entities must be gone after reload"
|
"the deactivated child's entities must be gone after reload"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,100 +142,61 @@ async def test_deactivated_child_entities_removed_on_reload(
|
|||||||
async def test_reactivation_restores_entities_with_original_unique_ids(
|
async def test_reactivation_restores_entities_with_original_unique_ids(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
original = _child_unique_ids(BO)
|
||||||
original = _child_unique_ids(bo.id)
|
entry, _coordinator, _client = await _setup_with_household(
|
||||||
await set_child_active(entry.runtime_data.database, bo.id, False)
|
hass, make_entry, _BOTH, _BO_GONE, _BOTH
|
||||||
|
)
|
||||||
await _reload(hass, entry)
|
await _reload(hass, entry)
|
||||||
assert not (original & set(hass.entities))
|
assert not (original & set(hass.entities))
|
||||||
|
|
||||||
await set_child_active(entry.runtime_data.database, bo.id, True)
|
|
||||||
await _reload(hass, entry)
|
await _reload(hass, entry)
|
||||||
|
|
||||||
assert original <= set(hass.entities), (
|
assert original <= set(hass.entities), (
|
||||||
"reactivation must restore the exact original unique_ids"
|
"reactivation must restore the exact original unique_ids"
|
||||||
)
|
)
|
||||||
bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"]
|
bo_due = hass.entities[f"nestquest_child_{BO}_quests_due_today"]
|
||||||
assert bo_due.native_value == 1, "restored entities carry live state"
|
assert bo_due.native_value == 1, "restored entities carry live state"
|
||||||
|
|
||||||
|
|
||||||
async def test_new_child_gets_entities_without_disturbing_existing(
|
async def test_new_child_gets_entities_without_disturbing_existing(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
"""A child ADDED to the API's snapshot gains its entities on
|
||||||
before_ada = set(hass.entities) & _child_unique_ids(ada.id)
|
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)
|
||||||
|
|
||||||
cory = await create_child(entry.runtime_data.database, "Cory")
|
|
||||||
await _reload(hass, entry)
|
await _reload(hass, entry)
|
||||||
|
|
||||||
assert before_ada <= set(hass.entities), "existing entities untouched"
|
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"
|
"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(
|
async def test_restart_equivalent_restores_all_entity_states(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Unload + setup (the restart equivalent) restores every active
|
"""Unload + setup (the restart equivalent) restores every active
|
||||||
child's entities with correct states and the household rollups."""
|
child's entities with the API's current states and the household
|
||||||
from custom_components.nestquest.completion import complete_instance
|
rollups."""
|
||||||
|
entry, _coordinator, _client = await _setup_with_household(
|
||||||
entry, ada, bo = await _setup_and_seed(hass, make_entry)
|
hass, make_entry, _BOTH, _BO_DONE
|
||||||
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)
|
await _reload(hass, entry)
|
||||||
|
|
||||||
assert hass.entities[
|
assert hass.entities[
|
||||||
f"nestquest_child_{ada.id}_quests_completed_today"
|
f"nestquest_child_{ADA}_quests_completed_today"
|
||||||
].native_value == 1, "completed count survives the restart"
|
|
||||||
assert hass.entities[
|
|
||||||
f"nestquest_child_{bo.id}_quests_completed_today"
|
|
||||||
].native_value == 0
|
].native_value == 0
|
||||||
|
assert hass.entities[
|
||||||
|
f"nestquest_child_{BO}_quests_completed_today"
|
||||||
|
].native_value == 1, "the API's recorded completion survives the restart"
|
||||||
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities)
|
||||||
assert hass.entities[
|
assert hass.entities[
|
||||||
"nestquest_household_quests_completed_today"
|
"nestquest_household_quests_completed_today"
|
||||||
].native_value == 1
|
].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"
|
|
||||||
)
|
|
||||||
|
|||||||
+213
-153
@@ -2,74 +2,156 @@
|
|||||||
|
|
||||||
One fixture drives every assertion the done-condition names: correct
|
One fixture drives every assertion the done-condition names: correct
|
||||||
values for every sensor and binary sensor (including the zero-quest
|
values for every sensor and binary sensor (including the zero-quest
|
||||||
child and the 255-character state limit), the three events firing
|
child and the 255-character state limit), the completed/uncompleted/
|
||||||
exactly once with schema-valid payloads, the instances payload
|
day-complete events re-fired exactly once from the API's SSE frames,
|
||||||
omitting missed instances while admin_instances includes them, and no
|
the panel payload omitting missed instances while admin_instances
|
||||||
entities for an inactive child. The per-task test files cover each
|
equals it, and no entities for a child the API does not report. The
|
||||||
behavior in depth; this file is the single pass that proves them all
|
per-task test files cover each behavior in depth; this file is the
|
||||||
against ONE fixture, so a regression in any contract fails here too.
|
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.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from conftest import refire_api_transitions, wire_entry_to_registry
|
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
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, uncomplete_instance
|
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_CHILD_DAY_COMPLETE,
|
EVENT_CHILD_DAY_COMPLETE,
|
||||||
EVENT_QUEST_COMPLETED,
|
EVENT_QUEST_COMPLETED,
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.core.events import (
|
|
||||||
build_quest_uncompleted_events,
|
|
||||||
)
|
|
||||||
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$")
|
_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):
|
async def _fixture(hass, make_entry):
|
||||||
"""Two quest children, one zero-quest child, one inactive child."""
|
"""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)."""
|
||||||
entry = wire_entry_to_registry(
|
entry = wire_entry_to_registry(
|
||||||
make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry
|
make_entry(
|
||||||
)
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
||||||
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")
|
|
||||||
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],
|
hass.registry,
|
||||||
["morning", "evening"],
|
|
||||||
)
|
)
|
||||||
end = (today + datetime.timedelta(days=3)).isoformat()
|
client = ScriptedPanelClient(
|
||||||
await materialize(database, today.isoformat(), end, today=today)
|
_payload(
|
||||||
await entry.runtime_data.coordinator.async_refresh()
|
[
|
||||||
return entry, (ada, bo, cory, ghost)
|
_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
|
||||||
|
|
||||||
|
|
||||||
def _sensor(hass, child_id, kind):
|
def _sensor(hass, child_id, kind):
|
||||||
@@ -77,11 +159,11 @@ def _sensor(hass, child_id, kind):
|
|||||||
|
|
||||||
|
|
||||||
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
||||||
entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry)
|
entry, client = await _fixture(hass, make_entry)
|
||||||
coordinator = entry.runtime_data.coordinator
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
|
||||||
# --- Every active child has the full entity set; the inactive
|
# --- Every reported child has the full entity set; the child the
|
||||||
# child has none.
|
# API does not report has none.
|
||||||
kinds = (
|
kinds = (
|
||||||
"quests_due_today",
|
"quests_due_today",
|
||||||
"quests_completed_today",
|
"quests_completed_today",
|
||||||
@@ -91,36 +173,37 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
"all_done",
|
"all_done",
|
||||||
"present_today",
|
"present_today",
|
||||||
)
|
)
|
||||||
for child in (ada, bo, cory):
|
ghost_id = 99
|
||||||
|
for child in (ADA, BO, CORY):
|
||||||
for kind in kinds:
|
for kind in kinds:
|
||||||
assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind
|
assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind
|
||||||
for kind in kinds:
|
for kind in kinds:
|
||||||
assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, (
|
assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, (
|
||||||
"inactive children must have no entities"
|
"children absent from the API snapshot must have no entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Correct values against the fixture: Ada and Bo owe two
|
# --- Correct values against the fixture: Ada and Bo owe two
|
||||||
# quests (morning + evening), Cory owes none and is still 100%.
|
# quests (morning + evening), Cory owes none and is still 100%.
|
||||||
for child in (ada, bo):
|
for child in (ADA, BO):
|
||||||
assert _sensor(hass, child.id, "quests_due_today").native_value == 2
|
assert _sensor(hass, child, "quests_due_today").native_value == 2
|
||||||
assert (
|
assert (
|
||||||
_sensor(hass, child.id, "quests_completed_today").native_value == 0
|
_sensor(hass, child, "quests_completed_today").native_value == 0
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
_sensor(hass, child.id, "quests_remaining_today").native_value == 2
|
_sensor(hass, child, "quests_remaining_today").native_value == 2
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
_sensor(hass, child.id, "completion_pct_today").native_value == 0
|
_sensor(hass, child, "completion_pct_today").native_value == 0
|
||||||
)
|
)
|
||||||
assert _sensor(hass, child.id, "next_quest").native_value == (
|
assert _sensor(hass, child, "next_quest").native_value == (
|
||||||
"Brush teeth"
|
"Brush teeth"
|
||||||
)
|
)
|
||||||
assert _sensor(hass, child.id, "all_done").is_on is False
|
assert _sensor(hass, child, "all_done").is_on is False
|
||||||
assert _sensor(hass, child.id, "present_today").is_on is True
|
assert _sensor(hass, child, "present_today").is_on is True
|
||||||
assert _sensor(hass, cory.id, "quests_due_today").native_value == 0
|
assert _sensor(hass, CORY, "quests_due_today").native_value == 0
|
||||||
assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100
|
assert _sensor(hass, CORY, "completion_pct_today").native_value == 100
|
||||||
assert _sensor(hass, cory.id, "all_done").is_on is False
|
assert _sensor(hass, CORY, "all_done").is_on is False
|
||||||
assert _sensor(hass, cory.id, "next_quest").native_value == "none"
|
assert _sensor(hass, CORY, "next_quest").native_value == "none"
|
||||||
|
|
||||||
# --- Household rollups equal the per-child sums.
|
# --- Household rollups equal the per-child sums.
|
||||||
assert hass.entities[
|
assert hass.entities[
|
||||||
@@ -140,27 +223,31 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
), unique_id
|
), unique_id
|
||||||
|
|
||||||
# --- Completing both of Ada's quests fires the events exactly
|
# --- Completing both of Ada's quests fires the events exactly
|
||||||
# once each and flips her entities immediately.
|
# once each and flips her entities immediately. The transition
|
||||||
instances = await QuestInstancesDao(
|
# events arrive over the API's SSE stream (the subscription
|
||||||
entry.runtime_data.database
|
# re-fires them), never from the service handler.
|
||||||
).list_by_date_range(
|
await hass.services.call(
|
||||||
ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat()
|
DOMAIN,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
|
{"instance_id": 7, "actor": "panel", "actor_child_id": ADA},
|
||||||
)
|
)
|
||||||
for instance in instances:
|
await hass.services.call(
|
||||||
await hass.services.call(
|
DOMAIN,
|
||||||
DOMAIN,
|
SERVICE_COMPLETE_QUEST,
|
||||||
SERVICE_COMPLETE_QUEST,
|
{"instance_id": 8, "actor": "panel", "actor_child_id": ADA},
|
||||||
{
|
|
||||||
"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
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||||
@@ -169,71 +256,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
assert payload["quest_title"] == "Brush teeth"
|
assert payload["quest_title"] == "Brush teeth"
|
||||||
assert isinstance(payload["instance_id"], int)
|
assert isinstance(payload["instance_id"], int)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||||
assert _sensor(hass, ada.id, "all_done").is_on is True
|
# The next scripted snapshot reflects both completions.
|
||||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0
|
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 (
|
assert (
|
||||||
_sensor(hass, ada.id, "completion_pct_today").native_value == 100
|
_sensor(hass, ADA, "completion_pct_today").native_value == 100
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Reversing one quest fires the uncompleted event exactly once
|
# --- Reversing one quest fires the uncompleted event exactly once.
|
||||||
# and restores the owed counts. The HA ``uncomplete_quest``
|
# The HA ``uncomplete_quest`` service is gone (the API service's
|
||||||
# service is gone (the API service's admin route is the reversal
|
# admin route is the reversal path), so the reversal is the API's:
|
||||||
# path): drive the core completion layer it calls, publish the
|
# it publishes the uncompleted frame and the integration re-fires
|
||||||
# uncompleted transition the way the API does, and deliver it
|
# it; the coordinator's next poll picks the restored counts up.
|
||||||
# through the SSE subscription.
|
client.published_frames.append(
|
||||||
reversal = await uncomplete_instance(
|
(
|
||||||
entry.runtime_data.database,
|
EVENT_QUEST_UNCOMPLETED,
|
||||||
instances[0].id,
|
{
|
||||||
actor_source="user",
|
"child_id": ADA,
|
||||||
actor_user_id="admin-1",
|
"child_name": "Ada",
|
||||||
)
|
"instance_id": 7,
|
||||||
assert reversal.appended is True
|
"quest_title": "Brush teeth",
|
||||||
client = coordinator.api_client
|
"window": "morning",
|
||||||
client.published_frames.extend(
|
"due_date": "2026-09-23",
|
||||||
await build_quest_uncompleted_events(
|
"due_time": "08:00",
|
||||||
entry.runtime_data.database, instances[0].id
|
"occurred_at": _OCCURRED,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await refire_api_transitions(hass, entry, client)
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1
|
||||||
# The reversal no longer carries an immediate local refresh: the
|
|
||||||
# coordinator's next poll picks the restored counts up.
|
# --- 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.
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
assert _sensor(hass, ada.id, "all_done").is_on is False
|
attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes
|
||||||
assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1
|
assert attributes["instances"] == [
|
||||||
|
dict(_instance(9, BO)),
|
||||||
# --- The instances payload omits missed instances (D-009) — and
|
dict(_instance(10, BO, window="evening")),
|
||||||
# since Feature 18 the snapshot comes from the API panel route,
|
], "the panel payload round-trips the API's open rows verbatim"
|
||||||
# which omits missed rows ENTIRELY, so admin_instances cannot
|
assert attributes["admin_instances"] == attributes["instances"], (
|
||||||
# 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.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 "
|
"the API payload omits missed rows, so the admin payload "
|
||||||
"carries none either (the PWA is the admin surface now)"
|
"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(
|
async def test_payload_schema_matches_the_documented_contract(
|
||||||
@@ -241,25 +310,16 @@ async def test_payload_schema_matches_the_documented_contract(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Every fired payload carries the documented fields with the
|
"""Every fired payload carries the documented fields with the
|
||||||
documented types."""
|
documented types."""
|
||||||
entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry)
|
entry, client = 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(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_COMPLETE_QUEST,
|
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]
|
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||||
for key, check in (
|
for key, check in (
|
||||||
("child_id", lambda v: isinstance(v, int)),
|
("child_id", lambda v: isinstance(v, int)),
|
||||||
|
|||||||
+215
-700
File diff suppressed because it is too large
Load Diff
+45
-392
@@ -9,12 +9,8 @@ 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 import DOMAIN, async_setup_entry, async_unload_entry
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_DAY_ROLLOVER_TIME,
|
|
||||||
CONF_HORIZON_DAYS,
|
|
||||||
CONF_MORNING_SUMMARY_TIME,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
SERVICE_COMPLETE_QUEST,
|
|
||||||
DOMAIN as DOMAIN_CONST,
|
DOMAIN as DOMAIN_CONST,
|
||||||
|
SERVICE_COMPLETE_QUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,15 +25,25 @@ 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
|
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
|
async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None:
|
||||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
entry = _wire(make_entry(), hass.registry)
|
||||||
await async_setup_entry(hass, entry)
|
await async_setup_entry(hass, entry)
|
||||||
runtime = entry.runtime_data
|
runtime = entry.runtime_data
|
||||||
assert runtime.entry_id == entry.entry_id
|
assert runtime.entry_id == entry.entry_id
|
||||||
assert runtime.settings.horizon_days == 7
|
assert runtime.coordinator is not None
|
||||||
assert callable(runtime.remove_update_listener)
|
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:
|
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
|
||||||
registry = hass.registry
|
registry = hass.registry
|
||||||
entry = _wire(make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
@@ -112,7 +118,7 @@ async def test_options_update_triggers_reload_once(hass, make_entry) -> None:
|
|||||||
registry = hass.registry
|
registry = hass.registry
|
||||||
entry = _wire(make_entry(), registry)
|
entry = _wire(make_entry(), registry)
|
||||||
await async_setup_entry(hass, entry)
|
await async_setup_entry(hass, entry)
|
||||||
entry.options = {"horizon_days": 30}
|
entry.options = {"update_interval": 60}
|
||||||
await registry.async_dispatch_options_update(entry)
|
await registry.async_dispatch_options_update(entry)
|
||||||
assert hass.config_entries.async_reload.call_count == 1
|
assert hass.config_entries.async_reload.call_count == 1
|
||||||
assert registry.reloaded == [entry.entry_id]
|
assert registry.reloaded == [entry.entry_id]
|
||||||
@@ -190,6 +196,16 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
|||||||
assert set(hass.data) == {DOMAIN_CONST}
|
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:
|
async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||||
"""A coroutine-returning remover is awaited during unload."""
|
"""A coroutine-returning remover is awaited during unload."""
|
||||||
removed = []
|
removed = []
|
||||||
@@ -208,9 +224,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
|
|||||||
assert entry.runtime_data is None
|
assert entry.runtime_data is None
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
async def test_setup_unwinds_when_listener_registration_fails() -> None:
|
||||||
"""A listener-registration failure must not leak the opened database."""
|
"""A listener-registration failure must not leak partial setup state."""
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||||
|
|
||||||
class _Boom(Exception):
|
class _Boom(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -220,13 +236,16 @@ async def test_setup_closes_database_when_listener_registration_fails() -> None:
|
|||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
with pytest.raises(_Boom):
|
with pytest.raises(_Boom):
|
||||||
await async_setup_entry(hass, entry)
|
await async_setup_entry(hass, entry)
|
||||||
assert hass.data.get(DOMAIN, {}) == {}
|
assert hass.data.get(DOMAIN_CONST, {}) == {}
|
||||||
assert not hasattr(entry, "runtime_data")
|
assert not hasattr(entry, "runtime_data")
|
||||||
|
assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST)
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
|
async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None:
|
||||||
"""Unload removes the listener, closes the DB in a finally, then raises."""
|
"""A raising update-listener remover must not skip the coordinator
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
shutdown: the retained error is re-raised only after the teardown
|
||||||
|
ran and the runtime record was cleared."""
|
||||||
|
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||||
|
|
||||||
class _RemoveBoom(Exception):
|
class _RemoveBoom(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -238,383 +257,17 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
|
|||||||
entry.add_update_listener = lambda listener: _remover
|
entry.add_update_listener = lambda listener: _remover
|
||||||
hass, _registry = make_hass()
|
hass, _registry = make_hass()
|
||||||
await async_setup_entry(hass, entry)
|
await async_setup_entry(hass, entry)
|
||||||
database = entry.runtime_data.database
|
coordinator = entry.runtime_data.coordinator
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def _shutdown():
|
||||||
|
seen.append("shutdown")
|
||||||
|
|
||||||
|
coordinator.async_shutdown = _shutdown
|
||||||
|
|
||||||
with pytest.raises(_RemoveBoom):
|
with pytest.raises(_RemoveBoom):
|
||||||
await async_unload_entry(hass, entry)
|
await async_unload_entry(hass, entry)
|
||||||
assert database.connected is False, "database leaked despite remover error"
|
assert seen == ["shutdown"], "coordinator shutdown must have run"
|
||||||
assert entry.runtime_data is None
|
assert entry.runtime_data is None
|
||||||
assert DOMAIN not in hass.data
|
assert DOMAIN_CONST 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
|
assert hass.time_change.size == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_rollover_time_change_reregisters_listener_at_new_time(
|
|
||||||
hass, make_entry
|
|
||||||
) -> None:
|
|
||||||
"""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 hass.time_change.size == 1
|
|
||||||
registration = hass.time_change.registrations[0]
|
|
||||||
assert (registration["hour"], registration["minute"]) == (6, 30)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_day_rollover_listener_materializes_ha_local_horizon(
|
|
||||||
hass, make_entry, monkeypatch
|
|
||||||
) -> 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
|
|
||||||
|
|
||||||
entry = _wire(make_entry(), hass.registry)
|
|
||||||
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_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None:
|
|
||||||
"""The daily run materializes only the configured horizon window."""
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
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
|
|
||||||
database = entry.runtime_data.database
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
)
|
|
||||||
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_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_COMPLETE_QUEST)
|
|
||||||
assert not hasattr(entry, "runtime_data")
|
|
||||||
+16
-16
@@ -6,29 +6,29 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_presence import PresenceSchedulesDao
|
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
schedule_rule_to_storage,
|
schedule_rule_to_storage,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.materialize import (
|
from custom_components.nestquest.core.materialize import (
|
||||||
materialize,
|
materialize,
|
||||||
regenerate_for_child,
|
regenerate_for_child,
|
||||||
regenerate_for_definition,
|
regenerate_for_definition,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
from custom_components.nestquest.quest_definitions import (
|
from custom_components.nestquest.core.quest_definitions import (
|
||||||
create_quest_definition,
|
create_quest_definition,
|
||||||
edit_quest_definition,
|
edit_quest_definition,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
occurs_on,
|
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:
|
def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
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:
|
def test_materialize_honours_overrides(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
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:
|
def test_materialize_skips_inactive_definitions(tmp_path) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.quest_definitions import (
|
from custom_components.nestquest.core.quest_definitions import (
|
||||||
set_quest_definition_active,
|
set_quest_definition_active,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -906,7 +906,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history(
|
|||||||
tmp_path,
|
tmp_path,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
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:
|
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
||||||
"""Regenerating one child must never touch another child's rows."""
|
"""Regenerating one child must never touch another child's rows."""
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
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)
|
monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp)
|
||||||
|
|
||||||
async def _body(database):
|
async def _body(database):
|
||||||
from custom_components.nestquest.dao_presence import (
|
from custom_components.nestquest.core.dao_presence import (
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.presence import PresenceSchedule
|
from custom_components.nestquest.core.presence import PresenceSchedule
|
||||||
|
|
||||||
children = ChildrenDao(database)
|
children = ChildrenDao(database)
|
||||||
schedules = PresenceSchedulesDao(database)
|
schedules = PresenceSchedulesDao(database)
|
||||||
|
|||||||
+4
-134
@@ -9,16 +9,16 @@ from unittest.mock import AsyncMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL
|
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
|
||||||
from custom_components.nestquest.migrations import (
|
from custom_components.nestquest.core.migrations import (
|
||||||
MIGRATIONS,
|
MIGRATIONS,
|
||||||
VERSION_TABLE,
|
VERSION_TABLE,
|
||||||
VERSION_TABLE_DDL,
|
VERSION_TABLE_DDL,
|
||||||
apply_migrations,
|
apply_migrations,
|
||||||
read_schema_version,
|
read_schema_version,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.schema import (
|
from custom_components.nestquest.core.schema import (
|
||||||
SCHEMA_V1_STATEMENTS,
|
SCHEMA_V1_STATEMENTS,
|
||||||
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
|
||||||
)
|
)
|
||||||
@@ -1064,133 +1064,3 @@ def test_partial_pending_migrations_resume_from_current_version(
|
|||||||
assert "step_b" in _tables(database)
|
assert "step_b" in _tables(database)
|
||||||
finally:
|
finally:
|
||||||
_run(database.close())
|
_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"
|
|
||||||
+47
-503
@@ -14,33 +14,16 @@ 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 config_flow
|
||||||
from custom_components.nestquest import options_flow
|
from custom_components.nestquest import options_flow
|
||||||
from custom_components.nestquest.const import (
|
from custom_components.nestquest.const import (
|
||||||
CONF_ADMIN_USER_IDS,
|
|
||||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
|
||||||
CONF_AFTERNOON_REMINDER_TIME,
|
|
||||||
CONF_API_BASE_URL,
|
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_IDLE_TIMEOUT,
|
||||||
CONF_PANEL_TOKEN,
|
CONF_PANEL_TOKEN,
|
||||||
CONF_SNAPSHOT_STALENESS,
|
CONF_SNAPSHOT_STALENESS,
|
||||||
CONF_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL,
|
||||||
DEFAULT_API_BASE_URL,
|
DEFAULT_API_BASE_URL,
|
||||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||||
DEFAULT_AUTOMATION_ENABLED,
|
|
||||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
|
||||||
DEFAULT_MORNING_SUMMARY_TIME,
|
|
||||||
DEFAULT_PANEL_TOKEN,
|
DEFAULT_PANEL_TOKEN,
|
||||||
DEFAULT_SNAPSHOT_STALENESS,
|
DEFAULT_SNAPSHOT_STALENESS,
|
||||||
DEFAULT_UPDATE_INTERVAL,
|
DEFAULT_UPDATE_INTERVAL,
|
||||||
DEFAULT_DAY_ROLLOVER_TIME,
|
|
||||||
DEFAULT_HORIZON_DAYS,
|
|
||||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,27 +32,17 @@ STRINGS_PATH = PKG_DIR / "strings.json"
|
|||||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||||
|
|
||||||
VALID_INPUT = {
|
VALID_INPUT = {
|
||||||
CONF_HORIZON_DAYS: 7,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||||
}
|
}
|
||||||
|
|
||||||
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
||||||
#: fresh entry: the Feature 10 refresh interval, the Feature 11
|
#: fresh entry: the Feature 10 refresh interval, the Feature 18
|
||||||
#: notification section, and the Feature 18 API connection fill from
|
#: staleness threshold, and the Feature 18 API connection fill from
|
||||||
#: their defaults.
|
#: their defaults.
|
||||||
FULL_INPUT = {
|
FULL_INPUT = {
|
||||||
**VALID_INPUT,
|
**VALID_INPUT,
|
||||||
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
||||||
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
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_API_BASE_URL: DEFAULT_API_BASE_URL,
|
||||||
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
||||||
}
|
}
|
||||||
@@ -87,48 +60,13 @@ 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)
|
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||||
|
|
||||||
|
|
||||||
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
|
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
||||||
"""Create an options flow instance bound to a stubbed config entry.
|
"""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 = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||||
flow.hass = SimpleNamespace()
|
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
|
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:
|
def _schema_values(schema) -> dict:
|
||||||
"""Extract the per-field default values from a real voluptuous schema."""
|
"""Extract the per-field default values from a real voluptuous schema."""
|
||||||
return {
|
return {
|
||||||
@@ -155,11 +93,11 @@ def test_options_flow_get_options_flow_is_ha_callback() -> None:
|
|||||||
|
|
||||||
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
||||||
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||||
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
|
||||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||||
assert isinstance(flow, _options_flow_base())
|
assert isinstance(flow, _options_flow_base())
|
||||||
assert flow.config_entry is entry
|
assert flow.config_entry is entry
|
||||||
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
|
||||||
|
|
||||||
|
|
||||||
def test_options_flow_submits_through_2024_6_surface() -> None:
|
def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||||
@@ -194,86 +132,66 @@ def test_options_flow_prefills_from_defaults() -> None:
|
|||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(None))
|
result = _run(flow.async_step_init(None))
|
||||||
values = _schema_values(result["data_schema"])
|
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_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:
|
def test_options_flow_prefills_from_entry_options() -> None:
|
||||||
"""Stored entry options win over entry data and defaults."""
|
"""Stored entry options win over entry data and defaults."""
|
||||||
entry = _make_entry(
|
entry = _make_entry(
|
||||||
options={
|
options={
|
||||||
CONF_HORIZON_DAYS: 21,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 450,
|
||||||
},
|
},
|
||||||
data={
|
data={
|
||||||
CONF_HORIZON_DAYS: 99,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "23:59",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 999,
|
CONF_PANEL_IDLE_TIMEOUT: 999,
|
||||||
|
CONF_UPDATE_INTERVAL: 999,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 999,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
values = _schema_values(result["data_schema"])
|
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_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:
|
def test_options_flow_falls_back_to_entry_data() -> None:
|
||||||
"""Pre-upgrade entries without options fall back to entry.data."""
|
"""Pre-upgrade entries without options fall back to entry.data."""
|
||||||
entry = _make_entry(
|
entry = _make_entry(
|
||||||
data={
|
data={
|
||||||
CONF_HORIZON_DAYS: 10,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "01:15",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 90,
|
CONF_PANEL_IDLE_TIMEOUT: 90,
|
||||||
|
CONF_UPDATE_INTERVAL: 60,
|
||||||
|
CONF_SNAPSHOT_STALENESS: 240,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
values = _schema_values(result["data_schema"])
|
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_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:
|
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(
|
entry = _make_entry(
|
||||||
options={
|
options={
|
||||||
CONF_HORIZON_DAYS: 21,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
CONF_UPDATE_INTERVAL: 90,
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
CONF_SNAPSHOT_STALENESS: 450,
|
CONF_SNAPSHOT_STALENESS: 450,
|
||||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
CONF_PANEL_TOKEN: "stored-token",
|
||||||
CONF_AFTERNOON_REMINDER_TIME: "14:00",
|
|
||||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
result = _run(_make_flow(entry).async_step_init(None))
|
result = _run(_make_flow(entry).async_step_init(None))
|
||||||
assert result["data_schema"]({}) == {
|
assert result["data_schema"]({}) == {
|
||||||
CONF_HORIZON_DAYS: 21,
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||||
CONF_UPDATE_INTERVAL: 90,
|
CONF_UPDATE_INTERVAL: 90,
|
||||||
CONF_SNAPSHOT_STALENESS: 450,
|
CONF_SNAPSHOT_STALENESS: 450,
|
||||||
CONF_NOTIFY_TARGET: "notify.mobile_app_test",
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
||||||
CONF_MORNING_SUMMARY_TIME: "07:00",
|
CONF_PANEL_TOKEN: "stored-token",
|
||||||
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: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -283,55 +201,14 @@ def test_options_flow_schema_coerces_field_types() -> None:
|
|||||||
result = _run(flow.async_step_init(None))
|
result = _run(flow.async_step_init(None))
|
||||||
schema = result["data_schema"]
|
schema = result["data_schema"]
|
||||||
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
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_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.
|
# The Feature 18 API connection fields are free-text strings.
|
||||||
assert validators[CONF_API_BASE_URL] is str
|
assert validators[CONF_API_BASE_URL] is str
|
||||||
assert validators[CONF_PANEL_TOKEN] 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])
|
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
|
||||||
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||||
"""Non-numeric or too-low idle timeouts yield a field-level error."""
|
"""Non-numeric or too-low idle timeouts yield a field-level error."""
|
||||||
@@ -343,10 +220,21 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
|||||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
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:
|
def test_options_flow_creates_entry_with_validated_keys() -> None:
|
||||||
"""A valid submit creates an entry with exactly the validated keys
|
"""A valid submit creates an entry with exactly the validated keys
|
||||||
(the legacy settings plus every later section's keys, filled from
|
(the client-facing settings, filled from defaults where the raw
|
||||||
defaults where the raw submit omitted them)."""
|
submit omitted them)."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -361,7 +249,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
|||||||
entry = _make_entry()
|
entry = _make_entry()
|
||||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||||
|
|
||||||
# Simulate setup registering the merged lifecycle update listener.
|
# Simulate setup registering the lifecycle update listener.
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
|
|
||||||
_run(async_setup_entry(hass, entry))
|
_run(async_setup_entry(hass, entry))
|
||||||
@@ -380,14 +268,13 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once(
|
|||||||
|
|
||||||
def test_options_flow_validation_never_raises() -> None:
|
def test_options_flow_validation_never_raises() -> None:
|
||||||
"""Validation returns errors instead of raising for arbitrary junk input."""
|
"""Validation returns errors instead of raising for arbitrary junk input."""
|
||||||
junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None}
|
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(junk))
|
result = _run(flow.async_step_init(junk))
|
||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
assert result["errors"] == {
|
assert result["errors"] == {
|
||||||
CONF_HORIZON_DAYS: "invalid",
|
|
||||||
CONF_DAY_ROLLOVER_TIME: "invalid_time",
|
|
||||||
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
||||||
|
CONF_UPDATE_INTERVAL: "invalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -417,10 +304,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di
|
|||||||
assert init["title"]
|
assert init["title"]
|
||||||
assert init["description"]
|
assert init["description"]
|
||||||
data = init["data"]
|
data = init["data"]
|
||||||
for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT):
|
for key in (CONF_PANEL_IDLE_TIMEOUT,):
|
||||||
assert data.get(key), f"Missing field name for '{key}'"
|
assert data.get(key), f"Missing field name for '{key}'"
|
||||||
errors = options["errors"]
|
errors = options["errors"]
|
||||||
for key in ("invalid", "invalid_time"):
|
for key in ("invalid",):
|
||||||
assert errors.get(key), f"Missing error string for '{key}'"
|
assert errors.get(key), f"Missing error string for '{key}'"
|
||||||
|
|
||||||
|
|
||||||
@@ -476,349 +363,6 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
|||||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
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)
|
# Panel-plane API connection section (Feature 18)
|
||||||
@@ -889,7 +433,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_API_BASE_URL] == "http://panel.lan:8443"
|
||||||
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
||||||
|
|
||||||
# A raw submit carrying only the legacy keys keeps the stored
|
# A raw submit carrying only the panel-idle key keeps the stored
|
||||||
# API connection settings.
|
# API connection settings.
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -976,7 +520,7 @@ def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
|||||||
|
|
||||||
def test_options_flow_staleness_defaults_saved() -> None:
|
def test_options_flow_staleness_defaults_saved() -> None:
|
||||||
"""A bare submit saves the staleness default (three update
|
"""A bare submit saves the staleness default (three update
|
||||||
intervals) alongside the other timing defaults."""
|
intervals) alongside the other defaults."""
|
||||||
flow = _make_flow(_make_entry())
|
flow = _make_flow(_make_entry())
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
@@ -998,7 +542,7 @@ def test_options_flow_staleness_stored_values_round_trip() -> None:
|
|||||||
schema_defaults = form["data_schema"]({})
|
schema_defaults = form["data_schema"]({})
|
||||||
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
||||||
|
|
||||||
# A raw submit carrying only the legacy keys keeps the stored
|
# A raw submit carrying only the panel-idle key keeps the stored
|
||||||
# staleness (and never trips the interval cross-check).
|
# staleness (and never trips the interval cross-check).
|
||||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.presence import (
|
from custom_components.nestquest.core.presence import (
|
||||||
MAX_CYCLE_LENGTH_WEEKS,
|
MAX_CYCLE_LENGTH_WEEKS,
|
||||||
PresenceEngine,
|
PresenceEngine,
|
||||||
PresenceOverride,
|
PresenceOverride,
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.dao_children import ChildrenDao
|
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||||
from custom_components.nestquest.dao_instances import (
|
from custom_components.nestquest.core.dao_instances import (
|
||||||
CompletionEventsDao,
|
CompletionEventsDao,
|
||||||
QuestInstancesDao,
|
QuestInstancesDao,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.dao_rules import (
|
from custom_components.nestquest.core.dao_rules import (
|
||||||
QuestDefinitionsDao,
|
QuestDefinitionsDao,
|
||||||
ScheduleRulesDao,
|
ScheduleRulesDao,
|
||||||
schedule_rule_to_storage,
|
schedule_rule_to_storage,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.migrations import apply_migrations
|
from custom_components.nestquest.core.migrations import apply_migrations
|
||||||
from custom_components.nestquest.quest_definitions import (
|
from custom_components.nestquest.core.quest_definitions import (
|
||||||
CreatedQuestDefinition,
|
CreatedQuestDefinition,
|
||||||
assign_child,
|
assign_child,
|
||||||
create_quest_definition,
|
create_quest_definition,
|
||||||
@@ -31,7 +31,7 @@ from custom_components.nestquest.quest_definitions import (
|
|||||||
set_quest_definition_active,
|
set_quest_definition_active,
|
||||||
unassign_child,
|
unassign_child,
|
||||||
)
|
)
|
||||||
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
|
from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule
|
||||||
|
|
||||||
|
|
||||||
def _run(coro):
|
def _run(coro):
|
||||||
@@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster(
|
|||||||
)
|
)
|
||||||
bo = await children.create("Bo", NOW)
|
bo = await children.create("Bo", NOW)
|
||||||
|
|
||||||
import custom_components.nestquest.dao_rules as dao_rules
|
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||||
|
|
||||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
@@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None:
|
|||||||
start_date="2026-09-14",
|
start_date="2026-09-14",
|
||||||
)
|
)
|
||||||
|
|
||||||
import custom_components.nestquest.dao_rules as dao_rules
|
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||||
|
|
||||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||||
started = asyncio.Event()
|
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"]
|
database, "Brush teeth", _daily_rule(), [child.id], ["morning"]
|
||||||
)
|
)
|
||||||
|
|
||||||
import custom_components.nestquest.dao_rules as dao_rules
|
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||||
|
|
||||||
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
original_list = dao_rules.QuestDefinitionsDao.list_assignees
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
@@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit(
|
|||||||
start_date="2026-09-14",
|
start_date="2026-09-14",
|
||||||
)
|
)
|
||||||
|
|
||||||
import custom_components.nestquest.dao_rules as dao_rules
|
import custom_components.nestquest.core.dao_rules as dao_rules
|
||||||
|
|
||||||
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
original_list = dao_rules.QuestDefinitionsDao.list_windows
|
||||||
started = asyncio.Event()
|
started = asyncio.Event()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
RuleType,
|
RuleType,
|
||||||
occurs_on,
|
occurs_on,
|
||||||
@@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None:
|
|||||||
|
|
||||||
module_file = Path(
|
module_file = Path(
|
||||||
importlib.util.find_spec(
|
importlib.util.find_spec(
|
||||||
"custom_components.nestquest.recurrence"
|
"custom_components.nestquest.core.recurrence"
|
||||||
).origin
|
).origin
|
||||||
)
|
)
|
||||||
tree = ast.parse(module_file.read_text())
|
tree = ast.parse(module_file.read_text())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
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
|
than a silent wrong answer. Forged via a private enum extension
|
||||||
because ScheduleRule validation rejects unknown names."""
|
because ScheduleRule validation rejects unknown names."""
|
||||||
import enum as enum_module
|
import enum as enum_module
|
||||||
from custom_components.nestquest import recurrence as mod
|
from custom_components.nestquest.core import recurrence as mod
|
||||||
|
|
||||||
ForgedType = enum_module.Enum(
|
ForgedType = enum_module.Enum(
|
||||||
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
|
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
@@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
|
|||||||
"from homeassistant.core import HomeAssistant",
|
"from homeassistant.core import HomeAssistant",
|
||||||
"import sqlite3",
|
"import sqlite3",
|
||||||
"from .db import NestQuestDatabase",
|
"from .db import NestQuestDatabase",
|
||||||
"from custom_components.nestquest.db import NestQuestDatabase",
|
"from custom_components.nestquest.core.db import NestQuestDatabase",
|
||||||
"from custom_components.nestquest import db",
|
"from custom_components.nestquest import db",
|
||||||
"import custom_components.nestquest.dao_rules",
|
"import custom_components.nestquest.core.dao_rules",
|
||||||
"from . import db",
|
"from . import db",
|
||||||
"from . import schema",
|
"from . import schema",
|
||||||
"from custom_components.nestquest.migrations import apply_migrations",
|
"from custom_components.nestquest.core.migrations import apply_migrations",
|
||||||
]
|
]
|
||||||
for probe in probes:
|
for probe in probes:
|
||||||
offenders = _scan_forbidden_imports(probe)
|
offenders = _scan_forbidden_imports(probe)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import random
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import datetime
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.recurrence import (
|
from custom_components.nestquest.core.recurrence import (
|
||||||
RuleType,
|
RuleType,
|
||||||
RuleValidationError,
|
RuleValidationError,
|
||||||
ScheduleRule,
|
ScheduleRule,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import sqlite3
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.db import NestQuestDatabase
|
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||||
from custom_components.nestquest.schema import (
|
from custom_components.nestquest.core.schema import (
|
||||||
SCHEMA_V1_ADMIN_USERS_DDL,
|
SCHEMA_V1_ADMIN_USERS_DDL,
|
||||||
SCHEMA_V1_CHILDREN_DDL,
|
SCHEMA_V1_CHILDREN_DDL,
|
||||||
SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||||
|
|||||||
+230
-211
@@ -1,75 +1,132 @@
|
|||||||
"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2)."""
|
"""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.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
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.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 (
|
from custom_components.nestquest.sensor import (
|
||||||
MAX_STATE_LENGTH,
|
MAX_STATE_LENGTH,
|
||||||
_state_within_limit,
|
_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
|
|
||||||
|
|
||||||
#: The documented per-child day sensor kinds (unique_id suffixes).
|
def _today_iso() -> str:
|
||||||
SENSOR_KINDS = (
|
return datetime.date.today().isoformat()
|
||||||
"quests_due_today",
|
|
||||||
"quests_completed_today",
|
|
||||||
"quests_remaining_today",
|
|
||||||
"completion_pct_today",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _today() -> datetime.date:
|
def _instance(instance_id: int, child_id: int, **overrides) -> dict:
|
||||||
return datetime.date.today()
|
"""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
|
||||||
|
|
||||||
|
|
||||||
async def _seed_children(database):
|
def _child(child_id: int, name: str, instances=(), **overrides) -> dict:
|
||||||
"""Three active children: Ada and Bo share one daily quest; Cory has none."""
|
"""One child in the API route's payload shape, counts precomputed.
|
||||||
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()}
|
|
||||||
),
|
|
||||||
[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, cory
|
|
||||||
|
|
||||||
|
The API precomputes the per-child rollups from the full day; the
|
||||||
async def _setup_seeded_entry(hass, make_entry):
|
builder here derives them from the instance list so a payload can
|
||||||
"""Set up the integration, seed three children, refresh: sensors exist.
|
never drift from its own counts.
|
||||||
|
|
||||||
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)
|
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"}
|
||||||
|
),
|
||||||
|
hass.registry,
|
||||||
|
)
|
||||||
|
client = set_coordinator_client(entry, StubSnapshotClient(*payloads))
|
||||||
assert await async_setup_entry(hass, entry) is True
|
assert await async_setup_entry(hass, entry) is True
|
||||||
coordinator = entry.runtime_data.coordinator
|
coordinator = entry.runtime_data.coordinator
|
||||||
database = entry.runtime_data.database
|
assert coordinator.api_client is client
|
||||||
ada, bo, cory = await _seed_children(database)
|
return entry, coordinator, client
|
||||||
await coordinator.async_refresh()
|
|
||||||
return entry, coordinator, (ada, bo, cory)
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_child(coordinator, child_id):
|
def _snapshot_child(coordinator, child_id):
|
||||||
@@ -85,47 +142,31 @@ def _sensor(hass, child_id, kind):
|
|||||||
return hass.entities[f"{DOMAIN}_child_{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(
|
async def test_every_active_child_gets_five_sensors_with_documented_identity(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload(), _completed_payload()
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
|
||||||
assert hass.config_entries.forwarded_platforms == [
|
assert hass.config_entries.forwarded_platforms == [
|
||||||
"sensor",
|
"sensor",
|
||||||
"binary_sensor",
|
"binary_sensor",
|
||||||
]
|
]
|
||||||
await _complete_first_instance(database, coordinator, ada)
|
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
# Two quest children plus the zero-quest child: three children x
|
# Three children x (five sensors + two binary sensors) + three
|
||||||
# (five sensors + two binary sensors) + three household sensors,
|
# household sensors, no duplicates.
|
||||||
# no duplicates.
|
|
||||||
assert len(hass.entities) == 24
|
assert len(hass.entities) == 24
|
||||||
expected = {
|
expected = {
|
||||||
ada.id: ("Ada", 1, 1, 0, 100),
|
ADA: ("Ada", 1, 1, 0, 100),
|
||||||
bo.id: ("Bo", 1, 0, 1, 0),
|
BO: ("Bo", 1, 0, 1, 0),
|
||||||
cory.id: ("Cory", 0, 0, 0, 100),
|
CORY: ("Cory", 0, 0, 0, 100),
|
||||||
}
|
}
|
||||||
for child_id, (name, due, completed, remaining, pct) in expected.items():
|
for child_id, (name, due, completed, remaining, pct) in expected.items():
|
||||||
due_entity = _sensor(hass, child_id, "quests_due_today")
|
due_entity = _sensor(hass, child_id, "quests_due_today")
|
||||||
completed_entity = _sensor(hass, child_id, "quests_completed_today")
|
completed_entity = _sensor(hass, child_id, "quests_completed_today")
|
||||||
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
|
remaining_entity = _sensor(hass, child_id, "quests_remaining_today")
|
||||||
pct_entity = _sensor(hass, child_id, "completion_pct_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 due_entity.name == f"NestQuest {name} quests due today"
|
||||||
assert completed_entity.name == (
|
assert completed_entity.name == (
|
||||||
f"NestQuest {name} quests completed today"
|
f"NestQuest {name} quests completed today"
|
||||||
@@ -151,11 +192,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:
|
async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, _coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload()
|
||||||
)
|
)
|
||||||
due_entity = _sensor(hass, cory.id, "quests_due_today")
|
due_entity = _sensor(hass, CORY, "quests_due_today")
|
||||||
pct_entity = _sensor(hass, cory.id, "completion_pct_today")
|
pct_entity = _sensor(hass, CORY, "completion_pct_today")
|
||||||
assert due_entity.native_value == 0
|
assert due_entity.native_value == 0
|
||||||
assert pct_entity.native_value == 100, (
|
assert pct_entity.native_value == 100, (
|
||||||
"zero quests is a perfect day, not a division error"
|
"zero quests is a perfect day, not a division error"
|
||||||
@@ -168,14 +209,11 @@ async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None:
|
|||||||
async def test_due_today_attributes_carry_the_panel_payload(
|
async def test_due_today_attributes_carry_the_panel_payload(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _completed_payload()
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
|
||||||
await _complete_first_instance(database, coordinator, ada)
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
|
|
||||||
ada_due = _sensor(hass, ada.id, "quests_due_today")
|
ada_due = _sensor(hass, ADA, "quests_due_today")
|
||||||
attributes = ada_due.extra_state_attributes
|
attributes = ada_due.extra_state_attributes
|
||||||
assert set(attributes) == {
|
assert set(attributes) == {
|
||||||
"instances",
|
"instances",
|
||||||
@@ -184,7 +222,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
|
|||||||
"child_name",
|
"child_name",
|
||||||
"present",
|
"present",
|
||||||
}
|
}
|
||||||
assert attributes["child_id"] == ada.id
|
assert attributes["child_id"] == ADA
|
||||||
assert attributes["child_name"] == "Ada"
|
assert attributes["child_name"] == "Ada"
|
||||||
assert attributes["present"] is True
|
assert attributes["present"] is True
|
||||||
[instance] = attributes["instances"]
|
[instance] = attributes["instances"]
|
||||||
@@ -201,7 +239,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
|
|||||||
"completed_at",
|
"completed_at",
|
||||||
"on_time",
|
"on_time",
|
||||||
}
|
}
|
||||||
assert instance["child_id"] == ada.id
|
assert instance["child_id"] == ADA
|
||||||
assert instance["title"] == "Brush teeth"
|
assert instance["title"] == "Brush teeth"
|
||||||
assert instance["window"] == "morning"
|
assert instance["window"] == "morning"
|
||||||
assert instance["state"] == "completed", (
|
assert instance["state"] == "completed", (
|
||||||
@@ -214,7 +252,7 @@ async def test_due_today_attributes_carry_the_panel_payload(
|
|||||||
"with nothing missed, admin and panel payloads are identical"
|
"with nothing missed, admin and panel payloads are identical"
|
||||||
)
|
)
|
||||||
|
|
||||||
bo_due = _sensor(hass, bo.id, "quests_due_today")
|
bo_due = _sensor(hass, BO, "quests_due_today")
|
||||||
[bo_instance] = bo_due.extra_state_attributes["instances"]
|
[bo_instance] = bo_due.extra_state_attributes["instances"]
|
||||||
assert bo_instance["state"] == "open"
|
assert bo_instance["state"] == "open"
|
||||||
assert bo_instance["completed_at"] is None
|
assert bo_instance["completed_at"] is None
|
||||||
@@ -222,64 +260,64 @@ async def test_due_today_attributes_carry_the_panel_payload(
|
|||||||
|
|
||||||
|
|
||||||
async def test_due_today_payload_omits_missed_admin_equals_panel(
|
async def test_due_today_payload_omits_missed_admin_equals_panel(
|
||||||
hass, make_entry, monkeypatch
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""D-009: the panel payload omits missed instances.
|
"""D-009: the panel payload omits missed instances.
|
||||||
|
|
||||||
Since Feature 18 the snapshot comes from the API panel route,
|
Since Feature 18 the snapshot comes from the API panel route,
|
||||||
which omits missed rows entirely (the admin surface for missed
|
which omits missed rows entirely (the admin surface for missed
|
||||||
quests is the PWA, reading the API directly). The entity payload
|
quests is the PWA, reading the API directly). The integration
|
||||||
therefore cannot carry missed rows any more: ``admin_instances``
|
renders exactly what the route sent: a snapshot can never carry a
|
||||||
equals ``instances`` — open and completed rows only. A missed
|
``missed`` view, ``admin_instances`` equals ``instances``, and the
|
||||||
instance is still derived locally (``due_date`` before the
|
precomputed counts arrive untouched — pinned here with an open
|
||||||
snapshot's "today"); the pinned-FUTURE clock at the
|
instance whose derived state reads missed (a past ``due_time``
|
||||||
``derive_state`` boundary makes today's open instances read
|
with the counts already rolled up the way the route builds them).
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
payload = _payload(
|
||||||
hass, make_entry
|
[
|
||||||
|
_child(
|
||||||
|
BO,
|
||||||
|
"Bo",
|
||||||
|
[
|
||||||
|
_instance(
|
||||||
|
8,
|
||||||
|
BO,
|
||||||
|
due_time="00:01",
|
||||||
|
overdue=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
import custom_components.nestquest.core.snapshot as snapshot_module
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||||
|
|
||||||
tomorrow = _today() + datetime.timedelta(days=1)
|
bo_due = _sensor(hass, BO, "quests_due_today")
|
||||||
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
|
attributes = bo_due.extra_state_attributes
|
||||||
assert attributes["instances"] == [], (
|
assert attributes["instances"], "an open row renders in the panel payload"
|
||||||
"missed instances are omitted from the panel payload"
|
assert attributes["admin_instances"] == attributes["instances"], (
|
||||||
)
|
|
||||||
assert attributes["admin_instances"] == [], (
|
|
||||||
"the API payload omits missed rows, so the admin payload "
|
"the API payload omits missed rows, so the admin payload "
|
||||||
"carries none either (the PWA is the admin surface now)"
|
"carries none either (the PWA is the admin surface now)"
|
||||||
)
|
)
|
||||||
# Counts still roll the missed instance up as not-completed: the
|
# The counts arrive precomputed from the route's own build, which
|
||||||
# counts arrive precomputed from the route's own build, which rolls
|
# rolls the FULL day (any missed row included) into the numbers.
|
||||||
# the FULL day (the missed row included) into the numbers.
|
assert _sensor(hass, BO, "quests_completed_today").native_value == 0
|
||||||
assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0
|
assert _sensor(hass, BO, "quests_remaining_today").native_value == 1
|
||||||
assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("kind", SENSOR_KINDS)
|
@pytest.mark.parametrize("kind", (
|
||||||
|
"quests_due_today",
|
||||||
|
"quests_completed_today",
|
||||||
|
"quests_remaining_today",
|
||||||
|
"completion_pct_today",
|
||||||
|
))
|
||||||
async def test_no_sensor_state_exceeds_the_255_character_limit(
|
async def test_no_sensor_state_exceeds_the_255_character_limit(
|
||||||
hass, make_entry, kind
|
hass, make_entry, kind
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, _coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload()
|
||||||
)
|
)
|
||||||
for child in (ada, bo, cory):
|
for child_id in (ADA, BO, CORY):
|
||||||
entity = _sensor(hass, child.id, kind)
|
entity = _sensor(hass, child_id, kind)
|
||||||
state = entity.native_value
|
state = entity.native_value
|
||||||
assert len(str(state)) <= MAX_STATE_LENGTH
|
assert len(str(state)) <= MAX_STATE_LENGTH
|
||||||
|
|
||||||
@@ -295,14 +333,13 @@ def test_state_within_limit_refuses_overlong_strings() -> None:
|
|||||||
async def test_sensors_update_on_refresh_after_a_completion(
|
async def test_sensors_update_on_refresh_after_a_completion(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload(), _completed_payload()
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
completed_entity = _sensor(hass, ADA, "quests_completed_today")
|
||||||
completed_entity = _sensor(hass, ada.id, "quests_completed_today")
|
remaining_entity = _sensor(hass, ADA, "quests_remaining_today")
|
||||||
remaining_entity = _sensor(hass, ada.id, "quests_remaining_today")
|
pct_entity = _sensor(hass, ADA, "completion_pct_today")
|
||||||
pct_entity = _sensor(hass, ada.id, "completion_pct_today")
|
due_entity = _sensor(hass, ADA, "quests_due_today")
|
||||||
due_entity = _sensor(hass, ada.id, "quests_due_today")
|
|
||||||
assert completed_entity.native_value == 0
|
assert completed_entity.native_value == 0
|
||||||
assert remaining_entity.native_value == 1
|
assert remaining_entity.native_value == 1
|
||||||
assert pct_entity.native_value == 0
|
assert pct_entity.native_value == 0
|
||||||
@@ -318,7 +355,6 @@ async def test_sensors_update_on_refresh_after_a_completion(
|
|||||||
|
|
||||||
due_entity.async_write_ha_state = _record_write
|
due_entity.async_write_ha_state = _record_write
|
||||||
|
|
||||||
await _complete_first_instance(database, coordinator, ada)
|
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
assert completed_entity.native_value == 1
|
assert completed_entity.native_value == 1
|
||||||
@@ -330,8 +366,8 @@ async def test_sensors_update_on_refresh_after_a_completion(
|
|||||||
async def test_repeated_refreshes_do_not_duplicate_entities(
|
async def test_repeated_refreshes_do_not_duplicate_entities(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload()
|
||||||
)
|
)
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
@@ -341,8 +377,9 @@ async def test_repeated_refreshes_do_not_duplicate_entities(
|
|||||||
async def test_setup_with_no_children_registers_only_household(
|
async def test_setup_with_no_children_registers_only_household(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
entry, _coordinator, _client = await _setup_entry(
|
||||||
assert await async_setup_entry(hass, entry) is True
|
hass, make_entry, _payload([])
|
||||||
|
)
|
||||||
# No children means no per-child entities; the three household
|
# No children means no per-child entities; the three household
|
||||||
# rollups still exist (an empty household is a valid 0).
|
# rollups still exist (an empty household is a valid 0).
|
||||||
assert set(hass.entities) == {
|
assert set(hass.entities) == {
|
||||||
@@ -354,8 +391,7 @@ async def test_setup_with_no_children_registers_only_household(
|
|||||||
|
|
||||||
|
|
||||||
async def test_unload_unloads_platforms(hass, make_entry) -> None:
|
async def test_unload_unloads_platforms(hass, make_entry) -> None:
|
||||||
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry)
|
||||||
assert await async_setup_entry(hass, entry) is True
|
|
||||||
assert await async_unload_entry(hass, entry) is True
|
assert await async_unload_entry(hass, entry) is True
|
||||||
assert hass.config_entries.unloaded_platforms == [
|
assert hass.config_entries.unloaded_platforms == [
|
||||||
"sensor",
|
"sensor",
|
||||||
@@ -366,20 +402,23 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None:
|
|||||||
async def test_rename_propagates_to_name_and_device_label(
|
async def test_rename_propagates_to_name_and_device_label(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A manage_child rename shows on the next refresh: the name and
|
"""A rename in the API's snapshot shows on the next refresh: the
|
||||||
device label re-derive from the snapshot while the unique_id (and
|
name and device label re-derive from the snapshot while the
|
||||||
therefore history) is untouched."""
|
unique_id (and therefore history) is untouched."""
|
||||||
from custom_components.nestquest.children import edit_child
|
renamed = _payload(
|
||||||
|
[
|
||||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
_child(ADA, "Mirren", [_instance(7, ADA)]),
|
||||||
hass, make_entry
|
_child(BO, "Bo", [_instance(8, BO)]),
|
||||||
|
_child(CORY, "Cory"),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
entry, coordinator, _client = await _setup_entry(
|
||||||
due_sensor = _sensor(hass, ada.id, "quests_due_today")
|
hass, make_entry, _seeded_payload(), renamed
|
||||||
|
)
|
||||||
|
due_sensor = _sensor(hass, ADA, "quests_due_today")
|
||||||
original_unique_id = due_sensor.unique_id
|
original_unique_id = due_sensor.unique_id
|
||||||
assert due_sensor.name.startswith("NestQuest Ada ")
|
assert due_sensor.name.startswith("NestQuest Ada ")
|
||||||
|
|
||||||
await edit_child(database, ada.id, display_name="Mirren")
|
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
|
|
||||||
assert due_sensor.name.startswith("NestQuest Mirren ")
|
assert due_sensor.name.startswith("NestQuest Mirren ")
|
||||||
@@ -391,12 +430,11 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
|
|||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""When HA reports platforms still loaded, the unload must NOT
|
"""When HA reports platforms still loaded, the unload must NOT
|
||||||
close the database the live entities read: it returns False and
|
tear down the coordinator the live entities read: it returns False
|
||||||
keeps the runtime record for a retry."""
|
and keeps the runtime record for a retry."""
|
||||||
entry, _coordinator, _children = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload()
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
|
||||||
|
|
||||||
async def _refuse_unload(entry_arg, platforms):
|
async def _refuse_unload(entry_arg, platforms):
|
||||||
return False
|
return False
|
||||||
@@ -404,26 +442,27 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime(
|
|||||||
hass.config_entries.async_unload_platforms = _refuse_unload
|
hass.config_entries.async_unload_platforms = _refuse_unload
|
||||||
assert await async_unload_entry(hass, entry) is False
|
assert await async_unload_entry(hass, entry) is False
|
||||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||||
assert database.connected is True
|
assert entry.runtime_data.coordinator is coordinator
|
||||||
|
|
||||||
|
|
||||||
async def test_next_quest_sensor_reports_earliest_open_quest(
|
async def test_next_quest_sensor_reports_earliest_open_quest(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The next-quest sensor carries the earliest open quest's title in
|
"""The next-quest sensor carries the earliest open quest's title in
|
||||||
state with the full shape in attributes."""
|
state with the full shape in attributes."""
|
||||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _seeded_payload()
|
||||||
)
|
)
|
||||||
next_entity = _sensor(hass, ada.id, "next_quest")
|
next_entity = _sensor(hass, ADA, "next_quest")
|
||||||
assert next_entity.name == f"NestQuest Ada next quest"
|
assert next_entity.name == f"NestQuest Ada next quest"
|
||||||
assert next_entity.native_value == "Brush teeth"
|
assert next_entity.native_value == "Brush teeth"
|
||||||
attributes = next_entity.extra_state_attributes
|
attributes = next_entity.extra_state_attributes
|
||||||
assert attributes["title"] == "Brush teeth"
|
assert attributes["title"] == "Brush teeth"
|
||||||
assert attributes["window"] == "morning"
|
assert attributes["window"] == "morning"
|
||||||
assert attributes["instance_id"] == _snapshot_child(
|
assert attributes["instance_id"] == _snapshot_child(
|
||||||
coordinator, ada.id
|
coordinator, ADA
|
||||||
).instances[0].instance_id
|
).instances[0].instance_id
|
||||||
assert attributes["child_id"] == ada.id
|
assert attributes["child_id"] == ADA
|
||||||
|
|
||||||
|
|
||||||
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
||||||
@@ -431,14 +470,11 @@ async def test_next_quest_sensor_reports_none_sentinel_when_day_clear(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A child with every quest done (and a zero-quest child) reports
|
"""A child with every quest done (and a zero-quest child) reports
|
||||||
the documented sentinel, never an empty string."""
|
the documented sentinel, never an empty string."""
|
||||||
entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry(
|
entry, _coordinator, _client = await _setup_entry(
|
||||||
hass, make_entry
|
hass, make_entry, _completed_payload()
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
assert _sensor(hass, ADA, "next_quest").native_value == "none"
|
||||||
await _complete_first_instance(database, coordinator, ada)
|
assert _sensor(hass, CORY, "next_quest").native_value == "none"
|
||||||
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(
|
async def test_next_quest_state_truncates_over_the_limit(
|
||||||
@@ -446,23 +482,15 @@ async def test_next_quest_state_truncates_over_the_limit(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""A quest title longer than the state limit is truncated; the
|
"""A quest title longer than the state limit is truncated; the
|
||||||
attribute keeps the full title."""
|
attribute keeps the full title."""
|
||||||
entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry(
|
long_title = "A very long quest title. " * 30
|
||||||
hass, make_entry
|
payload = _payload(
|
||||||
|
[_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])]
|
||||||
)
|
)
|
||||||
database = entry.runtime_data.database
|
entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||||
from custom_components.nestquest.quest_definitions import (
|
next_entity = _sensor(hass, ADA, "next_quest")
|
||||||
edit_quest_definition,
|
full_title = _snapshot_child(
|
||||||
)
|
entry.runtime_data.coordinator, ADA
|
||||||
|
).instances[0].title
|
||||||
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
|
assert len(full_title) > 255
|
||||||
state = next_entity.native_value
|
state = next_entity.native_value
|
||||||
assert len(state) <= 255
|
assert len(state) <= 255
|
||||||
@@ -473,19 +501,21 @@ async def test_household_rollups_sum_the_active_children(
|
|||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Household due/completed equal the sum of the per-child sensors;
|
"""Household due/completed equal the sum of the per-child sensors;
|
||||||
the cycle-day sensor carries the coordinator's answer."""
|
the cycle-day sensor carries the coordinator's answer (precomputed
|
||||||
import datetime as _dt
|
by the API, which resolves the schedule engine server-side)."""
|
||||||
|
payload = _payload(
|
||||||
from custom_components.nestquest.dao_presence import (
|
[
|
||||||
PresenceSchedulesDao,
|
_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"),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
payload["cycle_day"] = 4
|
||||||
entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry(
|
entry, coordinator, _client = await _setup_entry(hass, make_entry, payload)
|
||||||
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_due = hass.entities["nestquest_household_quests_due_today"]
|
||||||
household_completed = hass.entities[
|
household_completed = hass.entities[
|
||||||
@@ -493,12 +523,12 @@ async def test_household_rollups_sum_the_active_children(
|
|||||||
]
|
]
|
||||||
cycle_day_entity = hass.entities["nestquest_cycle_day"]
|
cycle_day_entity = hass.entities["nestquest_cycle_day"]
|
||||||
per_child_due = [
|
per_child_due = [
|
||||||
_sensor(hass, child.id, "quests_due_today").native_value
|
_sensor(hass, child_id, "quests_due_today").native_value
|
||||||
for child in (ada, bo, cory)
|
for child_id in (ADA, BO, CORY)
|
||||||
]
|
]
|
||||||
per_child_completed = [
|
per_child_completed = [
|
||||||
_sensor(hass, child.id, "quests_completed_today").native_value
|
_sensor(hass, child_id, "quests_completed_today").native_value
|
||||||
for child in (ada, bo, cory)
|
for child_id in (ADA, BO, CORY)
|
||||||
]
|
]
|
||||||
assert household_due.native_value == sum(per_child_due)
|
assert household_due.native_value == sum(per_child_due)
|
||||||
assert household_completed.native_value == sum(per_child_completed)
|
assert household_completed.native_value == sum(per_child_completed)
|
||||||
@@ -507,16 +537,5 @@ async def test_household_rollups_sum_the_active_children(
|
|||||||
"NestQuest household quests completed today"
|
"NestQuest household quests completed today"
|
||||||
)
|
)
|
||||||
assert cycle_day_entity.name == "NestQuest cycle day"
|
assert cycle_day_entity.name == "NestQuest cycle day"
|
||||||
assert cycle_day_entity.native_value == 0, "no schedule anywhere"
|
|
||||||
|
|
||||||
# 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|",
|
|
||||||
)
|
|
||||||
await coordinator.async_refresh()
|
|
||||||
assert cycle_day_entity.native_value == 4
|
assert cycle_day_entity.native_value == 4
|
||||||
assert cycle_day_entity.extra_state_attributes["today"] == (
|
assert cycle_day_entity.extra_state_attributes["today"] == _today_iso()
|
||||||
today.isoformat()
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,472 +0,0 @@
|
|||||||
"""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}"
|
|
||||||
)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
"""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
|
|
||||||
Reference in New Issue
Block a user