From c5ed4250b62b3482af7c9c01f8c504fc21da6d21 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 09:01:21 -0400 Subject: [PATCH] The integration no longer owns a database The HA integration is now purely a client of the NestQuest API service (Feature 18): the API owns the SQLite file, the migrations, and every business rule, so keeping a second copy of that ownership inside the integration meant two sources of truth for household state, dead wiring (the startup backfill, the day-rollover materialization and sweep listeners, the corrupt-DB handling, the admin-allowlist seeding) and settings keys nothing consumes any more. Removed the integration's re-export/DB shim modules (db, store, migrations, schema, the five DAOs, completion, children, quest_definitions, admin_allowlist, presence, presence_management, materialize, recurrence, events, sweep); the bundled domain package at custom_components/nestquest/core/ is untouched and remains the single home of those engines. Setup now builds only the API client, the snapshot coordinator, and the SSE subscription, registers the one complete_quest proxy, and forwards the platforms. The options flow keeps only the keys the client itself consumes (API base URL + panel token, update interval, staleness, panel idle timeout); the horizon, rollover, notification and admin-allowlist settings moved out with the database (the API's settings store owns horizon/rollover, and the automations take their behaviour from blueprint inputs). The integration-plane tests now script the panel route's payloads and SSE frames instead of seeding a local database; the core-plane tests keep their coverage with imports pointed at core directly. --- custom_components/nestquest/__init__.py | 523 ++-------- .../nestquest/admin_allowlist.py | 11 - custom_components/nestquest/children.py | 11 - custom_components/nestquest/completion.py | 11 - custom_components/nestquest/config_flow.py | 22 +- custom_components/nestquest/dao_children.py | 12 - custom_components/nestquest/dao_instances.py | 12 - custom_components/nestquest/dao_meta.py | 11 - custom_components/nestquest/dao_presence.py | 11 - custom_components/nestquest/dao_rules.py | 17 - custom_components/nestquest/db.py | 31 - custom_components/nestquest/events.py | 81 -- custom_components/nestquest/materialize.py | 11 - custom_components/nestquest/migrations.py | 11 - custom_components/nestquest/options_flow.py | 374 ++----- custom_components/nestquest/presence.py | 11 - .../nestquest/presence_management.py | 10 - .../nestquest/quest_definitions.py | 11 - custom_components/nestquest/recurrence.py | 10 - custom_components/nestquest/schema.py | 11 - custom_components/nestquest/services.py | 2 +- custom_components/nestquest/store.py | 32 - custom_components/nestquest/strings.json | 12 +- custom_components/nestquest/sweep.py | 72 -- .../nestquest/translations/en.json | 12 +- tests/conftest.py | 261 ++--- tests/test_admin_allowlist.py | 217 +---- tests/test_binary_sensors.py | 251 ++--- tests/test_children_registry.py | 16 +- tests/test_complete_quest_proxy.py | 163 +--- tests/test_completion.py | 14 +- tests/test_config_flow.py | 20 +- tests/test_core_settings.py | 91 +- tests/test_core_snapshot.py | 47 +- tests/test_dao_children.py | 37 +- tests/test_dao_instances.py | 10 +- tests/test_dao_matrix.py | 12 +- tests/test_dao_presence.py | 16 +- tests/test_dao_rules.py | 12 +- tests/test_db.py | 67 +- tests/test_entity_lifecycle.py | 227 +++-- tests/test_entity_matrix.py | 366 ++++--- tests/test_events.py | 915 ++++-------------- tests/test_lifecycle.py | 437 +-------- tests/test_materialize.py | 32 +- tests/test_migrations.py | 138 +-- tests/test_options_flow.py | 550 +---------- tests/test_presence.py | 2 +- tests/test_quest_definitions.py | 22 +- tests/test_recurrence_bounds.py | 2 +- tests/test_recurrence_consolidated.py | 4 +- tests/test_recurrence_daily.py | 4 +- tests/test_recurrence_model.py | 8 +- tests/test_recurrence_monthly_day.py | 2 +- tests/test_recurrence_monthly_weekday.py | 2 +- tests/test_recurrence_property.py | 2 +- tests/test_recurrence_weekly.py | 2 +- tests/test_recurrence_yearly.py | 2 +- tests/test_schema.py | 4 +- tests/test_sensors.py | 441 +++++---- tests/test_startup_db.py | 472 --------- tests/test_store.py | 215 ---- tests/test_sweep.py | 259 ----- tests/test_timezones.py | 355 ------- 64 files changed, 1394 insertions(+), 5635 deletions(-) delete mode 100644 custom_components/nestquest/admin_allowlist.py delete mode 100644 custom_components/nestquest/children.py delete mode 100644 custom_components/nestquest/completion.py delete mode 100644 custom_components/nestquest/dao_children.py delete mode 100644 custom_components/nestquest/dao_instances.py delete mode 100644 custom_components/nestquest/dao_meta.py delete mode 100644 custom_components/nestquest/dao_presence.py delete mode 100644 custom_components/nestquest/dao_rules.py delete mode 100644 custom_components/nestquest/db.py delete mode 100644 custom_components/nestquest/events.py delete mode 100644 custom_components/nestquest/materialize.py delete mode 100644 custom_components/nestquest/migrations.py delete mode 100644 custom_components/nestquest/presence.py delete mode 100644 custom_components/nestquest/presence_management.py delete mode 100644 custom_components/nestquest/quest_definitions.py delete mode 100644 custom_components/nestquest/recurrence.py delete mode 100644 custom_components/nestquest/schema.py delete mode 100644 custom_components/nestquest/store.py delete mode 100644 custom_components/nestquest/sweep.py delete mode 100644 tests/test_startup_db.py delete mode 100644 tests/test_store.py delete mode 100644 tests/test_sweep.py delete mode 100644 tests/test_timezones.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 09da9ef..76493ca 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -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 -import asyncio -import datetime import inspect -import sqlite3 from collections.abc import Callable from dataclasses import dataclass -from pathlib import Path from typing import Any -from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.event import async_track_time_change from .const import ( - CONF_ADMIN_USER_IDS, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_STALENESS, @@ -26,315 +28,34 @@ from .const import ( LOGGER, PLATFORMS, ) -from .core.settings import NestQuestSettings -from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator, coordinator_client_from_entry -from .db import NestQuestDatabase, make_database -from .materialize import materialize as _materialize_run -from .migrations import apply_migrations +from .frontend import async_register_frontend from .services import async_deregister_services, async_register_services from .sse import NestQuestEventStream -from .store import async_get_db_path -from .admin_allowlist import seed_setup_admin -from .sweep import run_missed_sweep _LOGGER = LOGGER -#: SQLite reports "file is not a database" for corruption in every -#: sqlite3 version this integration targets; the version-specific -#: "encrypted or malformed" spelling (3.41+) maps to the same failure. -_CORRUPTION_MARKERS = ( - "file is not a database", - "database disk image is malformed", - "encrypted or malformed", - "is not a database", -) - -#: Primary sqlite error codes that mean "the file is corrupt" (SQLite -#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended -#: codes carry the primary code in their low byte, so the match is on -#: errcode & 0xff. -_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB - - -def _is_corruption_error(error: BaseException) -> bool: - """Return True when ``error`` indicates a corrupt database file. - - Classification order: sqlite_errorcode (Python 3.11+) is primary - and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched - on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.) - classify correctly too. Message matching is the fallback for - older Pythons: the corruption spellings are the documented English - texts; a non-English locale could evade them, but the code check - already covers every locale on supported Pythons. - """ - if not isinstance(error, sqlite3.DatabaseError): - return False - errorcode = getattr(error, "sqlite_errorcode", None) - if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES: - return True - message = str(error).lower() - return any(marker in message for marker in _CORRUPTION_MARKERS) - - -async def _preflight_existing_file( - hass: HomeAssistant, db_path: Path -) -> None: - """Detect corruption in an EXISTING file before it is opened read-write. - - Opening read-write persists WAL mode — for a rollback-journal file - that rewrites the header byte 18/19 and can create -wal/-shm - sidecars — which would modify a corrupt file before detection and - break the leave-untouched guarantee. This preflight therefore - validates the WHOLE file with a READ-ONLY connection (mode=ro + - immutable=1 URI, PRAGMA integrity_check, run on the executor like - every other DB call): corruption anywhere fails there, before - anything is written. ``immutable=1`` is SQLite's documented - guarantee that the connection performs NO writes and creates NO - sidecars — even a WAL-header file gets its -wal/-shm reads served - without creating the shared-memory file. Missing/empty files skip - the preflight (they are created fresh by the real open). All - filesystem stat/URI work runs inside the executor job with the - probe so the event loop never touches disk. - """ - def _probe() -> None: - # The file-state check and URI construction live inside the - # executor job: exists/stat/resolve are disk I/O. - if not db_path.exists(): - return - # A zero-byte file is neither missing nor a valid database: it - # is a corrupt (truncated-to-nothing) existing file, likely an - # interrupted first write. The leave-untouched contract applies - # to it too: refuse rather than silently initialize over it. - if db_path.stat().st_size == 0: - raise sqlite3.DatabaseError( - "existing database file is empty (zero bytes): file is " - "not a database" - ) - # Properly escape the path: '#'/'?'/'%' in a directory or file - # name would otherwise be parsed as URI fragments/parameters/ - # escapes. immutable=1: SQLite guarantees no writes and no - # sidecar creation from this connection. - uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1" - conn = sqlite3.connect(uri, uri=True) - try: - row = conn.execute("PRAGMA integrity_check").fetchone() - finally: - conn.close() - verdict = row[0] if row else "missing" - if verdict != "ok": - # A non-ok integrity verdict IS corruption: raise with the - # verdict text (which matches the corruption markers) and - # the NOTADB code so classification is code-based. - raise sqlite3.DatabaseError( - f"integrity_check verdict {verdict!r} on read-only " - f"probe: file is not a database" - ) - - try: - # Every DB-touching call goes through the executor (the mock - # harness's async_add_executor_job is awaitable too). - await hass.async_add_executor_job(_probe) - except sqlite3.DatabaseError as err: - # Non-ok integrity verdicts are corruption, full stop: there is - # no benign reading of a failed whole-file integrity check, so - # ANY DatabaseError out of the probe that carries a corruption - # marker or code propagates as corruption. Only exotic probe - # failures that classify as non-corruption (e.g. a transient - # 'database is locked' on the read) are downgraded to a debug - # log, letting the real open path surface them. - if _is_corruption_error(err): - raise - raise sqlite3.DatabaseError( - f"read-only corruption probe of {db_path} failed: {err}" - ) from err - -async def _async_open_database( - hass: HomeAssistant, db_path: Path -) -> NestQuestDatabase: - """Open the NestQuest database, mapping failure modes for setup. - - Three startup cases (Feature 02 done-condition): - - - file missing: SQLite creates it fresh on open — no special path, - migrations stamp version 1 on the next step. - - file present and valid: opens normally. - - file present but corrupt: log an ERROR with the path, close - anything opened, and raise ConfigEntryNotReady so HA retries — - the file is left byte-for-byte untouched so the owner can - restore a backup. No code path here deletes or overwrites an - existing database file. - - Corruption can surface at EITHER stage: a truncated header fails - in the read-only preflight (before the file is ever opened - read-write), while damaged later pages fail during migrations — - the preflight guarantees no WAL sidecar or header write happens - on a corrupt file before it is detected. Both are classified the - same way. - """ - try: - await _preflight_existing_file(hass, db_path) - database = await make_database(hass).open(db_path) - except asyncio.CancelledError: - raise - except sqlite3.DatabaseError as err: - if _is_corruption_error(err): - _LOGGER.error( - "NestQuest database at %s is corrupt: %s. Refusing to " - "start with a corrupt database; the file was left " - "untouched so a backup can be restored. Home Assistant " - "will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database corrupt: {db_path}" - ) from err - # open() failed for a non-corruption reason (locked file, - # permission problem): retryable, but nothing was opened. - _LOGGER.error( - "NestQuest database at %s failed to open: %s. Home " - "Assistant will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database unavailable: {db_path}" - ) from err - try: - await apply_migrations(database) - except (sqlite3.DatabaseError, RuntimeError) as err: - await database.close() - if _is_corruption_error(err): - _LOGGER.error( - "NestQuest database at %s is corrupt: %s. Refusing to " - "start with a corrupt database; the file was left " - "untouched so a backup can be restored. Home Assistant " - "will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database corrupt: {db_path}" - ) from err - # A non-corruption failure (e.g. a locked file, a future schema - # version) is also a retryable setup problem, but without the - # corruption framing. - _LOGGER.error( - "NestQuest database at %s failed to open: %s. Home " - "Assistant will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database unavailable: {db_path}" - ) from err - except asyncio.CancelledError: - await database.close() - raise - return database - @dataclass class NestQuestRuntimeData: """Runtime data stored on a NestQuest config entry.""" entry_id: str - settings: NestQuestSettings - database: NestQuestDatabase remove_update_listener: Callable[[], Any] - remove_time_change_listener: Callable[[], Any] coordinator: Any = None event_stream: NestQuestEventStream | None = None -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: - """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 database of its own; it resolves whichever config entry's runtime data is currently live at call time, so it cannot hold a stale - handle to a closed connection after another entry unloads. + handle to an unloaded entry's coordinator. """ for runtime_data in hass.data.get(DOMAIN, {}).values(): - database = getattr(runtime_data, "database", None) - if database is not None and database.connected: + if getattr(runtime_data, "coordinator", None) is not None: return runtime_data return None @@ -367,47 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" await async_register_frontend(hass) hass.data.setdefault(DOMAIN, {}) - existing = hass.data[DOMAIN].get(entry.entry_id) - if existing is not None: - db = getattr(existing, "database", None) - if db is None or not db.connected: - # Reopen through the SAME safety path as first setup: a - # record without a live connection (a previous setup's - # close-on-failure, or a hand-replaced file) must get the - # missing/valid/corrupt classification too, never a raw - # sqlite3 error and never skipped migrations — and the - # same allowlist seeding, so a reopened fresh file is - # never ownerless. - db = await _async_open_database( - hass, await async_get_db_path(hass) - ) - try: - stored_admin_ids = ( - entry.options.get(CONF_ADMIN_USER_IDS) - or entry.data.get(CONF_ADMIN_USER_IDS) - ) - context = getattr(entry, "context", None) - context_user_id = ( - context.get("user_id") - if isinstance(context, dict) - else None - ) - await seed_setup_admin( - db, - stored_admin_ids=stored_admin_ids, - context_user_id=context_user_id, - owner_ids=await _async_owner_user_ids(hass), - ) - except BaseException: - # A seeding failure must not leak the just-opened - # connection: close it before the error propagates, so - # HA's setup retry starts from a clean handle (the - # first-setup path closes symmetrically). - await db.close() - raise - existing.database = db - entry.runtime_data = existing - return True async def _async_update_listener( listener_hass: HomeAssistant, listener_entry: ConfigEntry @@ -415,80 +95,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Reload the entry when its options change.""" await listener_hass.config_entries.async_reload(listener_entry.entry_id) - database = await _async_open_database(hass, await async_get_db_path(hass)) remove_update_listener: Callable[[], Any] | None = None - remove_time_change_listener: Callable[[], Any] | None = None coordinator: Any = None event_stream: NestQuestEventStream | None = None try: - # Seed the admin allowlist on first setup so the owner is never - # locked out: an empty database takes the persisted admin copy - # — the options flow's saved list first (it is the current - # one), else the config flow's user from entry data — and only - # then the HA user still present on the entry context. No-op - # on a non-empty list (fail-closed narrowing is deliberate). - stored_admin_ids = ( - entry.options.get(CONF_ADMIN_USER_IDS) - or entry.data.get(CONF_ADMIN_USER_IDS) - ) - context = getattr(entry, "context", None) - context_user_id = ( - context.get("user_id") - if isinstance(context, dict) - else None - ) - owner_ids = await _async_owner_user_ids(hass) - await seed_setup_admin( - database, - stored_admin_ids=stored_admin_ids, - context_user_id=context_user_id, - owner_ids=owner_ids, - ) - # Build the explicit settings object from entry.options ONCE: the - # horizon materialization and the daily-rollover listener both - # consume it, so core never reads HA config itself. A malformed - # stored option falls back PER FIELD (via from_options_resilient) - # so a single bad sibling option cannot reset the user's - # horizon_days or day_rollover_time — the two fields core - # consumes — to their defaults. - settings = NestQuestSettings.from_options_resilient(entry.options) remove_update_listener = entry.add_update_listener(_async_update_listener) - remove_time_change_listener = _register_day_rollover_listener( - hass, database, settings - ) - # Backfill any days the daily listener missed while the integration - # was off (or freshly installed): run the one idempotent walk over - # [today, today + horizon_days] once at setup, using the entry's - # configured horizon. This runs through the SAME path as the - # daily listener. - await _run_horizon_materialization(hass, database, settings) - # The missed sweep runs at startup too (Feature 11): if HA was - # down at the rollover time, the previous night's still-open - # quests are announced as missed now instead of waiting for - # the next midnight. Watermark-guarded, so this is a no-op - # when the night's sweep already ran. - await run_missed_sweep(hass, database) _register_services(hass) - # The shared Feature 10 coordinator: one refresh cycle every - # entity reads from (CONF_UPDATE_INTERVAL seconds, default - # five minutes). Since Feature 18 it polls the API service's - # panel snapshot instead of reading the local database; an - # entry with no panel token yet gets the typed-error stub - # (unavailable entities, never a setup crash). Created AFTER - # services so a failure below unwinds them through the except - # path's listener handling. # ONE API client for the entry, shared by the coordinator's - # snapshot polling and the SSE subscription below (the client - # is stateless — one session, one connection pool). + # snapshot polling, the SSE subscription, and the completion + # proxy (the client is stateless — one session, one connection + # pool). An entry with no panel token yet gets the + # typed-error stub (unavailable entities, never a setup crash). api_client = coordinator_client_from_entry(hass, entry) - # 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. + # The shared coordinator: one refresh cycle every entity reads + # from (CONF_UPDATE_INTERVAL seconds, default five minutes). + # It polls the API service's panel snapshot; a failed poll + # keeps the entities rendering the last-good snapshot within + # CONF_SNAPSHOT_STALENESS, unavailable past it. coordinator = NestQuestCoordinator( hass, entry_id=entry.entry_id, @@ -513,20 +136,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: event_stream.start() runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, - settings=settings, - database=database, remove_update_listener=remove_update_listener, - remove_time_change_listener=remove_time_change_listener, coordinator=coordinator, event_stream=event_stream, ) # The runtime record is registered BEFORE the first refresh: - # the refresh path (and anything it calls) resolves the entry's - # runtime state through hass.data, and it must find the live - # database there — never a half-set-up entry. The platforms - # forward below reads the coordinator off the entry - # (the HA runtime-data contract); a forward failure below - # unwinds it through the except path. + # the service proxy resolves the entry's runtime state through + # hass.data, and it must find the live coordinator there — + # never a half-set-up entry. The platforms forward below reads + # the coordinator off the entry (the HA runtime-data contract); + # a forward failure below unwinds it through the except path. hass.data[DOMAIN][entry.entry_id] = runtime_data entry.runtime_data = runtime_data # 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 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: - # A failure anywhere after the database is open must not leak the - # listeners already registered against that (now-closed) database: - # unwind EVERY remover we acquired before the failure, keeping the - # original error as the one raised. Removal is best-effort — a - # failing remover must not mask the setup error that triggered it. - # The SSE subscription (started just before the runtime record) - # is stopped first: like the coordinator it must never outlive - # the database being closed below. + # A failure anywhere after the listener was registered must not + # leak it (or the SSE task): unwind EVERYTHING acquired before + # the failure, keeping the original error as the one raised. + # Removal is best-effort — a failing step must not mask the + # setup error that triggered it. if event_stream is not None: try: await event_stream.stop() except BaseException: pass - for remove_listener in ( - remove_time_change_listener, - remove_update_listener, - ): - if remove_listener is None: - continue + if remove_update_listener is not None: try: - result = remove_listener() - if inspect.isawaitable(result): - await result + remove_update_listener() except BaseException: pass # A coordinator created before the failure holds entity - # listeners pointed at the database being closed below — - # shut it down FIRST so no scheduled refresh can ever run - # against a closed connection. + # listeners pointed at the never-registered runtime — shut it + # down so no scheduled refresh outlives the failed setup. if coordinator is not None: try: await coordinator.async_shutdown() @@ -582,7 +190,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pass # A platform-forward failure reached this unwind AFTER # runtime_data was assigned: clear it so no later unload path - # 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: entry.runtime_data = None # 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 # re-invokes the already-consumed update-listener remover. hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) - await database.close() raise - assert remove_update_listener is not None - assert remove_time_change_listener is not None # The runtime record was already registered before the first # refresh; re-assert the (unchanged) record so the return path # stays readable as one registration site. @@ -612,17 +218,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) # Platforms unload FIRST (mirroring HA conventions): entity - # listeners must not outlive the database and coordinator the - # teardown below closes. + # listeners must not outlive the coordinator the teardown below + # shuts down. platforms_unloaded = await hass.config_entries.async_unload_platforms( entry, PLATFORMS ) if platforms_unloaded is False and runtime_data is not None: # Home Assistant KEPT one or more platforms loaded — their - # entities still live and must keep their database. Restore + # entities still live and must keep their coordinator. Restore # the runtime record popped above and report failure so HA # 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 return False 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) if stop_stream is not None: await stop_stream() - remove_update_listener = getattr(runtime_data, "remove_update_listener", None) - remove_time_change_listener = getattr( - runtime_data, "remove_time_change_listener", None + remove_update_listener = getattr( + runtime_data, "remove_update_listener", None ) try: - for remove_listener in ( - remove_update_listener, - remove_time_change_listener, - ): - if remove_listener is None: - continue - try: - result = remove_listener() - if inspect.isawaitable(result): - await result - except BaseException as err: - # Cancel EVERY listener even when an earlier remover - # raises: a skipped remover would leave a daily - # callback firing against a closed database after - # unload. The first error is retained and re-raised - # only after both removers (and the DB close) ran. - if unload_error is None: - unload_error = err + if remove_update_listener is not None: + result = remove_update_listener() + if inspect.isawaitable(result): + await result + except BaseException as err: + # A failing remover must not skip the coordinator shutdown + # below; the first error is retained and re-raised only + # after the teardown ran. + if unload_error is None: + unload_error = err finally: - # The coordinator's entity listeners must stop BEFORE the - # database closes: a scheduled refresh starting during the - # close await would touch a cleared connection. (The - # platform unload above already removed the entities; this - # clears the coordinator's own remaining listeners.) + # The coordinator's entity listeners must stop here: a + # scheduled refresh must never outlive the unload. coordinator = getattr(runtime_data, "coordinator", None) if coordinator is not None: shutdown = getattr(coordinator, "async_shutdown", None) if shutdown is not None: await shutdown() - database = getattr(runtime_data, "database", None) - if database is not None: - await database.close() # Domain-global services are torn down only once the FINAL entry # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling # entry is still loaded. diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py deleted file mode 100644 index 92f2bb5..0000000 --- a/custom_components/nestquest/admin_allowlist.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py deleted file mode 100644 index f936729..0000000 --- a/custom_components/nestquest/children.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py deleted file mode 100644 index d3b10b2..0000000 --- a/custom_components/nestquest/completion.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index 803b2f1..4f54b59 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.core import callback 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): @@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step of the config flow. - The HA user completing the flow is persisted into the entry - data as the initial admin allowlist copy: real HA does not - guarantee the flow context survives to setup, and entry data is - the persisted record first setup seeds from — the owner is - never locked out. + The integration needs no configuration to start (the API + connection is set in the options flow), so the user input is + stored as-is. The former admin-allowlist copy is gone: with + the database removed the allowlist lives in the API service's + own store, not on the entry. """ await self.async_set_unique_id(DOMAIN, raise_on_progress=False) 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: return self.async_show_form(step_id="user") - 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) \ No newline at end of file + return self.async_create_entry(title="NestQuest", data=dict(user_input)) \ No newline at end of file diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py deleted file mode 100644 index 6ee1e2e..0000000 --- a/custom_components/nestquest/dao_children.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py deleted file mode 100644 index 1ac0c9e..0000000 --- a/custom_components/nestquest/dao_instances.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py deleted file mode 100644 index bef9c16..0000000 --- a/custom_components/nestquest/dao_meta.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py deleted file mode 100644 index 422ae17..0000000 --- a/custom_components/nestquest/dao_presence.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py deleted file mode 100644 index 31c4ab9..0000000 --- a/custom_components/nestquest/dao_rules.py +++ /dev/null @@ -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, -) diff --git a/custom_components/nestquest/db.py b/custom_components/nestquest/db.py deleted file mode 100644 index b13a2e0..0000000 --- a/custom_components/nestquest/db.py +++ /dev/null @@ -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) diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py deleted file mode 100644 index 5a67c81..0000000 --- a/custom_components/nestquest/events.py +++ /dev/null @@ -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) diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py deleted file mode 100644 index a6cb1b1..0000000 --- a/custom_components/nestquest/materialize.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py deleted file mode 100644 index d51d3c7..0000000 --- a/custom_components/nestquest/migrations.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 29adee9..865d992 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -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 typing import Any @@ -6,52 +17,25 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_validation as cv from .const import ( - CONF_ADMIN_USER_IDS, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_AFTERNOON_REMINDER_TIME, CONF_API_BASE_URL, - CONF_CELEBRATION_ENABLED, - CONF_DAY_ROLLOVER_TIME, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_END_OF_DAY_REPORT_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_ENABLED, - CONF_MORNING_SUMMARY_TIME, - CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, - DEFAULT_AFTERNOON_REMINDER_TIME, - DEFAULT_AUTOMATION_ENABLED, - DEFAULT_DAY_ROLLOVER_TIME, - DEFAULT_END_OF_DAY_REPORT_TIME, - DEFAULT_HORIZON_DAYS, - DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, - DOMAIN, MIN_UPDATE_INTERVAL, - TIME_PATTERN, ) -from .admin_allowlist import list_admin_ids, set_admin_ids from .api_client import is_valid_base_url -MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 -def _parse_time(value: str) -> bool: - """Return True when value is a valid 24-hour HH:MM string.""" - return TIME_PATTERN.fullmatch(value) is not None - - def _async_validate( user_input: dict[str, Any], current: dict[str, Any] | None = None, @@ -66,18 +50,6 @@ def _async_validate( """ errors: dict[str, str] = {} - horizon_days = user_input.get(CONF_HORIZON_DAYS) - if ( - isinstance(horizon_days, bool) - or not isinstance(horizon_days, int) - or horizon_days < MIN_HORIZON_DAYS - ): - errors[CONF_HORIZON_DAYS] = "invalid" - - day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME) - if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time): - errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time" - panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT) if ( isinstance(panel_idle_timeout, bool) @@ -125,41 +97,6 @@ def _async_validate( if staleness < effective_interval: errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness" - # The notification section (Feature 11) is OPTIONAL in raw submits - # the same way: a missing key keeps the current value. The three - # times are strict HH:MM; the notify target is a non-empty HA - # service name; the toggles are real booleans. - for time_key in ( - CONF_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME, - ): - time_value = user_input.get(time_key) - if time_value is not None and ( - not isinstance(time_value, str) or not _parse_time(time_value) - ): - errors[time_key] = "invalid_time" - notify_target = user_input.get(CONF_NOTIFY_TARGET) - if notify_target is not None and not isinstance(notify_target, str): - errors[CONF_NOTIFY_TARGET] = "invalid" - elif isinstance(notify_target, str) and notify_target != ( - notify_target.strip() - ): - # An EMPTY target is valid — it means "no notifications - # configured yet" and is the shipped default; a value that - # differs from its own trim (padded or whitespace-only) is - # malformed and rejected. - errors[CONF_NOTIFY_TARGET] = "invalid" - for toggle_key in ( - CONF_MORNING_SUMMARY_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_CELEBRATION_ENABLED, - ): - toggle_value = user_input.get(toggle_key) - if toggle_value is not None and not isinstance(toggle_value, bool): - errors[toggle_key] = "invalid" - # The panel-plane API connection (Feature 18) is OPTIONAL in raw # submits the same way: a missing key keeps the current value. The # base URL must be a full http(s) URL with a host — the same check @@ -182,261 +119,78 @@ def _async_validate( return errors -def _strict_bool(value: object) -> bool: - """Require a real bool; voluptuous ``bool`` would coerce strings.""" - if not isinstance(value, bool): - raise vol.Invalid(f"expected a boolean, got {value!r}") - return value - - -def _build_schema( - current: dict[str, Any], - *, - admin_choices: dict[str, str] | None = None, - admin_default: list[str] | None = None, -) -> vol.Schema: +def _build_schema(current: dict[str, Any]) -> vol.Schema: """Build the form schema, pre-filled with the current values. - The admin picker is a multi-select of EXISTING Home Assistant users - (never a free-text field — the allowlist stores user IDs); its - default is the current database allowlist, so an untouched submit - re-saves exactly who is already allowed. The notification section - (Feature 11) carries the household's notify target, the three - automation times, and four enable toggles — defaults from - :mod:`~.const`, never a hard-coded personal target. + The API connection (Feature 18) carries the base URL the API client + talks to and the panel service token it presents as the Bearer + credential. Schema-validated submits always carry every field (the + defaults pre-fill from the stored values); raw callers omitting one + keep its stored value (see _async_validate). """ - fields: dict[Any, Any] = { - vol.Required( - CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] - ): int, - vol.Required( - CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] - ): str, - vol.Required( - CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] - ): int, - vol.Required( - CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] - ): int, - # The last-good staleness threshold (Feature 18): how long the - # coordinator keeps serving the cached snapshot after the API - # stops answering. Schema-validated submits always carry it - # (the default pre-fills from the stored value); raw callers - # omitting it keep their stored value (see _async_validate). - vol.Required( - CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS] - ): int, - vol.Required( - CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] - ): str, - vol.Required( - CONF_MORNING_SUMMARY_TIME, - default=current[CONF_MORNING_SUMMARY_TIME], - ): str, - vol.Required( - CONF_AFTERNOON_REMINDER_TIME, - default=current[CONF_AFTERNOON_REMINDER_TIME], - ): str, - vol.Required( - CONF_END_OF_DAY_REPORT_TIME, - default=current[CONF_END_OF_DAY_REPORT_TIME], - ): str, - vol.Required( - CONF_MORNING_SUMMARY_ENABLED, - default=current[CONF_MORNING_SUMMARY_ENABLED], - ): _strict_bool, - vol.Required( - CONF_AFTERNOON_REMINDER_ENABLED, - default=current[CONF_AFTERNOON_REMINDER_ENABLED], - ): _strict_bool, - vol.Required( - CONF_END_OF_DAY_REPORT_ENABLED, - default=current[CONF_END_OF_DAY_REPORT_ENABLED], - ): _strict_bool, - vol.Required( - CONF_CELEBRATION_ENABLED, - default=current[CONF_CELEBRATION_ENABLED], - ): _strict_bool, - # Panel-plane API connection (Feature 18): the base URL the API - # client talks to and the panel service token it presents as - # the Bearer credential. Schema-validated submits always carry - # both (the defaults pre-fill from the stored values); raw - # callers omitting them keep their stored values (see - # _async_validate). - vol.Required( - CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] - ): str, - vol.Required( - CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] - ): str, - } - if admin_choices is not None: - # HA's supported multi-select validator: the frontend can - # serialize the field as a real picker of the given choices. - fields[ - vol.Optional( - CONF_ADMIN_USER_IDS, default=list(admin_default or []) - ) - ] = cv.multi_select(admin_choices) - return vol.Schema(fields) + return vol.Schema( + { + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, + default=current[CONF_PANEL_IDLE_TIMEOUT], + ): int, + vol.Required( + CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] + ): int, + # The last-good staleness threshold (Feature 18): how long + # the coordinator keeps serving the cached snapshot after + # the API stops answering. + vol.Required( + CONF_SNAPSHOT_STALENESS, + default=current[CONF_SNAPSHOT_STALENESS], + ): int, + # Panel-plane API connection (Feature 18): the base URL the + # API client talks to and the panel service token it + # presents as the Bearer credential. + vol.Required( + CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] + ): str, + vol.Required( + CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] + ): str, + } + ) class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Handle the options flow for NestQuest.""" - def _async_database(self): - """Return the runtime database for this entry, or None. - - Real HA only opens the options flow for an existing entry, so - the runtime record (and its database) exists; the None branch - covers direct construction in tests and degraded states. - """ - hass_data = getattr(self.hass, "data", None) - if not isinstance(hass_data, dict): - return None - record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id) - database = getattr(record, "database", None) - return database if getattr(database, "connected", False) else None - - async def _async_admin_choices(self) -> dict[str, str]: - """Map HA user id -> display name for the picker. - - Real Home Assistant always provides ``hass.auth``; a missing - one (direct construction in tests, degraded harness) degrades - to an empty picker rather than crashing the form. - """ - auth = getattr(self.hass, "auth", None) - if auth is None: - return {} - users = await auth.async_get_users() - return { - user.id: user.name - for user in users - if getattr(user, "id", None) - } - - async def _async_current_admin_ids(self) -> list[str]: - """Return the current allowlist, or [] without a database.""" - database = self._async_database() - if database is None: - return [] - return await list_admin_ids(database) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the NestQuest options.""" - admin_choices = await self._async_admin_choices() - admin_default = await self._async_current_admin_ids() if user_input is None: return self.async_show_form( step_id="init", - data_schema=_build_schema( - self._current_values(), - admin_choices=admin_choices, - admin_default=admin_default, - ), + data_schema=_build_schema(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: 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, - ) - 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, - ), + data_schema=_build_schema(self._current_values()), errors=errors, ) - # Apply the picker's selection to the allowlist when the - # runtime database is reachable; the selection is always - # stored on the entry as the disaster-recovery copy. - database = self._async_database() - if database is not None and admin_ids_present: - await set_admin_ids(database, admin_ids) - data = { - CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], - CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], - CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], - CONF_UPDATE_INTERVAL: user_input.get( - CONF_UPDATE_INTERVAL, - self._current_values()[CONF_UPDATE_INTERVAL], - ), - CONF_SNAPSHOT_STALENESS: user_input.get( - CONF_SNAPSHOT_STALENESS, - self._current_values()[CONF_SNAPSHOT_STALENESS], - ), - } - # The notification section (Feature 11) stores the household's - # choices the same fall-back-to-current way, so raw submits - # that predate the section keep their stored values. + # Every remaining key is stored the fall-back-to-current way, so + # raw submits that predate a section keep that section's stored + # values. current = self._current_values() - for notification_key in ( - CONF_NOTIFY_TARGET, - CONF_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME, - CONF_MORNING_SUMMARY_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_CELEBRATION_ENABLED, + data: dict[str, Any] = {} + for key in ( + CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + CONF_SNAPSHOT_STALENESS, + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, ): - data[notification_key] = user_input.get( - notification_key, current[notification_key] - ) - # The API connection section (Feature 18) stores the base URL - # and panel token the same fall-back-to-current way, so raw - # submits that predate the section keep their stored values. - for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): - data[api_key] = user_input.get(api_key, current[api_key]) - if admin_ids_present: - data[CONF_ADMIN_USER_IDS] = admin_ids - elif admin_default: - # HA replaces entry.options wholesale, so an omitted picker - # field must carry the CURRENT allowlist forward: dropping - # it would leave only the stale config-flow copy in entry - # data, and a later database loss would resurrect admins - # the owner has since removed. - data[CONF_ADMIN_USER_IDS] = list(admin_default) + data[key] = user_input.get(key, current[key]) return self.async_create_entry(title="NestQuest", data=data) 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 {} values: dict[str, Any] = {} for key, default in ( - (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), - (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), (CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS), - (CONF_NOTIFY_TARGET, ""), - (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), - (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), - (CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME), - (CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_API_BASE_URL, DEFAULT_API_BASE_URL), (CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), ): @@ -467,4 +211,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): values[key] = entry_data[key] else: values[key] = default - return values \ No newline at end of file + return values diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py deleted file mode 100644 index 56538b3..0000000 --- a/custom_components/nestquest/presence.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/presence_management.py b/custom_components/nestquest/presence_management.py deleted file mode 100644 index 92f89be..0000000 --- a/custom_components/nestquest/presence_management.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py deleted file mode 100644 index 099fc46..0000000 --- a/custom_components/nestquest/quest_definitions.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py deleted file mode 100644 index 7e72994..0000000 --- a/custom_components/nestquest/recurrence.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py deleted file mode 100644 index 6ed278a..0000000 --- a/custom_components/nestquest/schema.py +++ /dev/null @@ -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 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index ad9f146..715d4cf 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -54,7 +54,7 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): runtime = find_runtime(hass) if runtime is None: raise HomeAssistantError( - "NestQuest has no live database; cannot run this service" + "NestQuest is not loaded; cannot run this service" ) return runtime diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py deleted file mode 100644 index e87a7d7..0000000 --- a/custom_components/nestquest/store.py +++ /dev/null @@ -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)) diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 1853eb4..446757a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -18,25 +18,19 @@ "step": { "init": { "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": { - "horizon_days": "Generation horizon (days)", - "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", - "panel_token": "Panel service token", - "admin_user_ids": "Admin users (parents)" + "panel_token": "Panel service token" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", - "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", - "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", - "invalid_admin": "Invalid admin selection." + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval." } } } diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py deleted file mode 100644 index 937f969..0000000 --- a/custom_components/nestquest/sweep.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 1853eb4..446757a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -18,25 +18,19 @@ "step": { "init": { "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": { - "horizon_days": "Generation horizon (days)", - "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", - "panel_token": "Panel service token", - "admin_user_ids": "Admin users (parents)" + "panel_token": "Panel service token" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", - "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", - "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", - "invalid_admin": "Invalid admin selection." + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval." } } } diff --git a/tests/conftest.py b/tests/conftest.py index 8af8125..efa617f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,6 @@ pytest -q``); it is never meant to run against a real HA install. from __future__ import annotations import asyncio -import datetime import importlib.util import inspect import sys @@ -742,160 +741,92 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry): return entry -class LazyLocalSnapshotClient: - """A test client that serves the snapshot built LOCALLY. +class StubSnapshotClient: + """A no-network stand-in for the API client's snapshot surface. - Feature 18 moved the coordinator's refresh onto the API service's - panel snapshot, but most integration tests still seed the local - database directly (the DB removal is a later task). This stub - bridges the two: ``get_snapshot`` resolves the entry's runtime - record (its database and settings) and runs the SAME pure core - builder the API route runs - (:func:`~custom_components.nestquest.core.snapshot.build_snapshot`), - shaping the payload exactly like ``api/routes_panel.py`` — - instances via ``instance_payload(include_missed=False)`` — so the - coordinator reconstructs from a byte-identical payload without any - network or FastAPI machinery. - - ``complete_instance`` bridges the panel complete route the same - way (the ``nestquest.complete_quest`` proxy calls it through the - API client): it resolves the entry's database and delegates to the - SAME core completion the API route delegates to, with the panel - actor shape, and answers the route's ``{"status": ...}`` body. - Like the real API service it publishes its transition events on - the SSE stream — NOT on the Home Assistant bus and NOT from the - service handler; tests that assert bus events drive them through - the SSE subscription the way production delivers them. - - The resolution is LAZY on purpose: the coordinator's factory runs - mid-setup, before the runtime record exists, so the first refresh - (and any refresh while the entry is torn down) serves an EMPTY but - valid snapshot; every later refresh reads the seeded database. + Answers ``get_snapshot()`` from a scripted queue of outcomes — a + payload dict or an exception per call, consumed in order — and + records every call. When the queue is exhausted it re-serves the + last outcome, so setup's first refresh and a test's explicit + refreshes poll the same scripted state. Register it (or any + scripted client) with :func:`set_coordinator_client` BEFORE setup. """ - def __init__(self, hass, entry_id: str) -> None: - self._hass = hass - self._entry_id = entry_id + def __init__(self, *outcomes) -> None: self.calls = 0 - #: Every ``(instance_id, CompletionResult)`` this stand-in - # produced, in call order — tests read ``appended`` / - # ``was_on_time`` off these to check the completion verdicts. - self.completions = [] - #: The SSE frames the stand-in published for its completions - # (the API service's stream content), drained by - #: :func:`refire_api_transitions`. + self._outcomes = list(outcomes) + self._last = outcomes[-1] if outcomes else None + #: The SSE frames the stand-in "published" (the API service's + # stream content), drained by :func:`refire_api_transitions`. self.published_frames = [] - def _live_runtime(self): - """The entry's runtime record, or ``None`` before/after setup.""" - from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN - - runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id) - database = getattr(runtime, "database", None) - if database is None or not getattr(database, "connected", True): - return None - return runtime - - def _live_database(self): - """The entry's open database, or ``None`` before/after setup.""" - runtime = self._live_runtime() - return None if runtime is None else runtime.database - async def get_snapshot(self): - import datetime - import zoneinfo - - from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN - from custom_components.nestquest.core import snapshot as core_snapshot - self.calls += 1 - runtime = self._live_runtime() - if runtime is None: - return { - "today_iso": datetime.date.today().isoformat(), - "cycle_day": 0, - "children": [], - } - database = runtime.database - settings = getattr(runtime, "settings", None) - # The HA-LOCAL now, the same single clock read the production - # coordinator used to hand the builder: the hass stub's - # configured time zone, never the host clock. - now = datetime.datetime.now( - zoneinfo.ZoneInfo(self._hass.config.time_zone) - ) - snapshot = await core_snapshot.build_snapshot(database, settings, now) - return { - "today_iso": snapshot.today_iso, - "cycle_day": snapshot.cycle_day, - "children": [ - { - "child_id": child.child_id, - "child_name": child.child_name, - "present": child.present, - "next_present": child.next_present, - "due_today": child.due_today, - "completed_today": child.completed_today, - "remaining_today": child.remaining_today, - "completion_pct": child.completion_pct, - "instances": core_snapshot.instance_payload( - child.instances, include_missed=False - ), - } - for child in snapshot.children - ], - } + if not self._outcomes: + # Re-serve the last outcome: setup's first refresh and the + # test's explicit pass both poll the same scripted state. + outcome = self._last + else: + outcome = self._outcomes.pop(0) + self._last = outcome + if isinstance(outcome, BaseException): + raise outcome + return outcome - 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 - from custom_components.nestquest.core import events as core_events +class ScriptedPanelClient(StubSnapshotClient): + """Snapshot + completion stand-in for the API service's panel plane. - result = await complete_instance( - self._live_database(), - instance_id, - actor_source="panel", - actor_child_id=actor_child_id, - ) - self.completions.append((instance_id, result)) - if result.appended: - # The route publishes the transition the moment the core - # write lands (appended completions only), evaluating the - # day-complete rule against the API host's local date — - # the frames queue here for the SSE stream to carry. - event_type, payload = await core_events.build_quest_completed_event( - self._live_database(), - instance_id, - was_on_time=result.was_on_time, - ) - self.published_frames.append((event_type, payload)) - now = datetime.datetime.now().astimezone() - day_complete = await core_events.build_child_day_complete_event( - self._live_database(), payload, today=now.date() - ) - if day_complete is not None: - self.published_frames.append(day_complete) - return {"status": str(result)} + Serves the scripted snapshot outcomes (like + :class:`StubSnapshotClient`), records ``complete_instance`` calls + the way the panel route receives them, optionally fails them the + way the client types the failure, and carries the SSE frames the + API "published" in ``published_frames`` for + :func:`refire_api_transitions` to deliver. Everything the + integration reads over the wire, no network. + """ + + def __init__(self, *outcomes) -> None: + super().__init__(*outcomes) + self.complete_calls: list[tuple[int, int]] = [] + self.published_frames: list[tuple[str, dict]] = [] + #: When set, the next ``complete_instance`` raises this instead + # of succeeding (a typed NestQuestApiError in production). + self.fail_complete_with: BaseException | None = None + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict: + self.complete_calls.append((instance_id, actor_child_id)) + if self.fail_complete_with is not None: + raise self.fail_complete_with + return {"status": "done"} + + +def set_coordinator_client(entry, client): + """Register a scripted client for ``entry``'s coordinator factory.""" + import custom_components.nestquest.coordinator as coordinator_module + + coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client + return client @pytest.fixture(autouse=True) -def coordinator_serves_local_snapshot(): - """Default every test entry's coordinator to the local-snapshot client. +def coordinator_client_from_test_override(): + """Resolve every entry's coordinator client through the test seam. The integration's setup builds its API client through - ``coordinator_client_from_entry``; the fixture swaps that factory - for a test one handing out :class:`LazyLocalSnapshotClient` (so - DB-seeded tests exercise the coordinator's API path against local - data). Tests that script their own client register it with - :func:`set_coordinator_client`, which the factory honours first. + ``coordinator_client_from_entry``; this fixture swaps that factory + for a test one handing out the client registered with + :func:`set_coordinator_client`, or — for an entry with no scripted + client — a stub serving an EMPTY but valid household snapshot (the + shape the API's panel route returns before any child exists; the + real client would need aiohttp, which this mock-only harness does + not provide). """ + import datetime + import unittest.mock as mock + import custom_components.nestquest as nq_mod 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) if override is not None: return override - return LazyLocalSnapshotClient(hass, entry.entry_id) - - import unittest.mock as mock + return StubSnapshotClient( + { + "today_iso": datetime.date.today().isoformat(), + "cycle_day": 0, + "children": [], + } + ) _COORDINATOR_CLIENT_OVERRIDES.clear() patcher = mock.patch.object( @@ -921,14 +856,6 @@ def coordinator_serves_local_snapshot(): _COORDINATOR_CLIENT_OVERRIDES.clear() -def set_coordinator_client(entry, client): - """Register a scripted client for ``entry``'s coordinator factory.""" - import custom_components.nestquest.coordinator as coordinator_module - - coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client - return client - - async def refire_api_transitions(hass, entry, client): """Deliver the SSE frames the stand-in client has published so far through the integration's SSE subscription — the production event @@ -1205,42 +1132,6 @@ def make_config_flow_entry(): return _factory -def freeze_nestquest_clock( - monkeypatch, instant: datetime.datetime -) -> tuple[datetime.datetime, list]: - """Freeze the integration's ``datetime`` clock to a fixed aware instant. - - Only ``custom_components.nestquest``'s ``datetime`` binding is patched, - so the generation path's ``datetime.datetime.now`` reads this instant - (localized to whatever time zone it asks for) while every other module - and the test harness keep the real clock. Returns ``(frozen, requested)`` - where ``frozen`` is the instant normalized to UTC and ``requested`` is - the list of ``tzinfo`` objects the code under test passed to ``now`` — - so a caller can prove the generation path actually requested the - configured HA time zone rather than silently reading UTC. - """ - import custom_components.nestquest as nestquest - - frozen = instant.astimezone(datetime.timezone.utc) - requested: list = [] - - class _FrozenDatetime(datetime.datetime): - @classmethod - def now(cls, tz=None): - requested.append(tz) - if tz is None: - return frozen.replace(tzinfo=None) - return frozen.astimezone(tz) - - fake_module = SimpleNamespace( - datetime=_FrozenDatetime, - timedelta=datetime.timedelta, - date=datetime.date, - ) - monkeypatch.setattr(nestquest, "datetime", fake_module) - return frozen, requested - - # --------------------------------------------------------------------------- # Admin-plane API fixtures (shared by the admin-plane test modules). # diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 1ca1224..800d0fe 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -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 import asyncio -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest import async_setup_entry, async_unload_entry -from custom_components.nestquest.admin_allowlist import ( +from custom_components.nestquest.core.admin_allowlist import ( list_admin_ids, seed_setup_admin, set_admin_ids, ) -from custom_components.nestquest.dao_children import AdminUsersDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations 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: async def _body(database): # 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) -async def test_reopened_setup_failure_closes_database( - hass, make_entry, monkeypatch -) -> None: - """A seeding failure on the reopened-record path closes the just- - opened connection before re-raising (no leaked handles across HA's - setup retries).""" - import pytest as pytest_module - from unittest.mock import AsyncMock - - import custom_components.nestquest as nestquest_module - from custom_components.nestquest.db import NestQuestDatabase - - # First: a successful setup to create the runtime record and file. - entry = await _setup_entry(hass, make_entry(), hass.registry) - await async_unload_entry(hass, entry) - - # Now a re-setup whose seeding fails after the reopen. - async def _failing_seed(database, **kwargs): - raise RuntimeError("seed boom") - - monkeypatch.setattr( - nestquest_module, "seed_setup_admin", _failing_seed, raising=True - ) - - close_calls: list[bool] = [] - original_close = NestQuestDatabase.close - - async def _spy_close(self): - close_calls.append(True) - return await original_close(self) - - monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) - - with pytest_module.raises(RuntimeError, match="seed boom"): - await _setup_entry(hass, entry, hass.registry) - # The reopen path opened AND closed a connection; the failure is - # the last thing observed. - assert close_calls[-1] is True - - -# --------------------------------------------------------------------------- # is_admin: the fail-closed resolver (Feature 09's verdict function) # --------------------------------------------------------------------------- def test_is_admin_verdict_matrix(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.admin_allowlist import is_admin + from custom_components.nestquest.core.admin_allowlist import is_admin await set_admin_ids(database, ["user-owner"]) 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: """An empty allowlist returns False for EVERYONE, not True.""" 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): 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 admin in place, so is_admin still resolves True.""" 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"]) with pytest.raises(ValueError, match="at least one admin"): diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index a4a31e2..d7d7b80 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -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 -import datetime - -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 -from custom_components.nestquest.children import create_child, list_children from custom_components.nestquest.const import DOMAIN -from custom_components.nestquest.dao_presence import PresenceSchedulesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule -from custom_components.nestquest.completion import complete_instance - -SEED_HORIZON_DAYS = 3 def _binary(hass, child_id: int, kind: str): return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] -async def _setup_entry(hass, make_entry): - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - return entry +def _child( + child_id: int, + name: str, + *, + present=True, + next_present=None, + instances=(), +): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": present, + "next_present": next_present, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } -async def _seed_two_children(database) -> tuple: - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} +def _instance(instance_id: int, child_id: int, *, state="open") -> dict: + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00", + "on_time": None if state == "open" else True, + } + + +def _payload(children) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +ADA = 1 +BO = 2 +CORY = 3 + + +async def _setup_entry(hass, make_entry, *payloads): + """Set up the integration with a scripted snapshot client.""" + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning"], + hass.registry, ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - return ada, bo + set_coordinator_client(entry, StubSnapshotClient(*payloads)) + assert await async_setup_entry(hass, entry) is True + return entry, entry.runtime_data.coordinator 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: """all_done is OFF while quests are owed AND on a zero-quest day; a child with no quests never fires the celebration automation.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") # zero quests - await coordinator.async_refresh() + _entry, coordinator = await _setup_entry( + hass, + make_entry, + _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO)]), + _child(CORY, "Cory"), + ] + ), + ) - assert _binary(hass, ada.id, "all_done").is_on is False - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - assert _binary(hass, cory.child_id, "all_done").is_on is False + assert _binary(hass, ADA, "all_done").is_on is False + assert _binary(hass, BO, "all_done").is_on is False + assert _binary(hass, CORY, "all_done").is_on is False async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None: - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await coordinator.async_refresh() - snapshot_ada = [ - child for child in coordinator.data.children - if child.child_id == ada.id - ][0] - instance_id = snapshot_ada.instances[0].instance_id - await complete_instance( - database, - instance_id, - actor_source="panel", - actor_child_id=ada.id, + _entry, coordinator = await _setup_entry( + hass, + make_entry, + _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]), + _payload( + [ + _child( + ADA, + "Ada", + instances=[_instance(7, ADA, state="completed")], + ) + ] + ), ) 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( hass, make_entry ) -> None: - """No schedule means present every day; an all-absent pattern - today reports OFF.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") - today = datetime.date.today() - await coordinator.async_refresh() - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - await PresenceSchedulesDao(database).upsert_by_child( - cory.child_id, 1, today.isoformat(), "" + """Presence is resolved by the API's presence engine: present=True + renders ON, present=False renders OFF.""" + _entry, _coordinator = await _setup_entry( + hass, + make_entry, + _payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]), ) - await coordinator.async_refresh() - assert _binary(hass, ada.id, "present_today").is_on is True - assert _binary(hass, cory.child_id, "present_today").is_on is False + assert _binary(hass, ADA, "present_today").is_on is True + assert _binary(hass, CORY, "present_today").is_on is False async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await coordinator.async_refresh() + _entry, _coordinator = await _setup_entry( + hass, make_entry, _payload([_child(ADA, "Ada")]) + ) - all_done = _binary(hass, ada.id, "all_done") - present = _binary(hass, ada.id, "present_today") - assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done" - assert present.unique_id == f"nestquest_child_{ada.id}_present_today" + all_done = _binary(hass, ADA, "all_done") + present = _binary(hass, ADA, "present_today") + assert all_done.unique_id == f"nestquest_child_{ADA}_all_done" + assert present.unique_id == f"nestquest_child_{ADA}_present_today" assert all_done.name == "NestQuest Ada all done" assert present.name == "NestQuest Ada present today" for entity in (all_done, present): assert entity.device_info.name == "NestQuest Ada" assert all_done.extra_state_attributes == { - "child_id": ada.id, + "child_id": ADA, "child_name": "Ada", } assert present.extra_state_attributes == { - "child_id": ada.id, + "child_id": ADA, "child_name": "Ada", "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 day (the panel's away plate renders ``Returns , `` from it); an always-absent pattern carries ``None``.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") - today = datetime.date.today() - pattern = ",".join( - str(day) for day in range(7) if day != today.weekday() + _entry, _coordinator = await _setup_entry( + hass, + make_entry, + _payload( + [ + _child( + ADA, + "Ada", + present=False, + next_present="2026-09-24", + ), + _child(CORY, "Cory", present=False, next_present=None), + ] + ), ) - 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.id, "present_today").extra_state_attributes[ + assert _binary(hass, ADA, "present_today").is_on is False + assert _binary(hass, ADA, "present_today").extra_state_attributes[ "next_present" - ] == (today + datetime.timedelta(days=1)).isoformat() + ] == "2026-09-24" - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - assert _binary(hass, cory.child_id, "present_today").is_on is False - assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[ + assert _binary(hass, CORY, "present_today").is_on is False + assert _binary(hass, CORY, "present_today").extra_state_attributes[ "next_present" ] is None - - -async def _cory_id(database) -> int: - for child in await list_children(database): - if child.display_name == "Cory": - return child.id - raise AssertionError("Cory was not created") diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 6ad47b5..cd48747 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest.children import ( +from custom_components.nestquest.core.children import ( create_child, edit_child, list_children, reorder_children, set_child_active, ) -from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events( async def _body(database): from datetime import date, timedelta - from custom_components.nestquest.dao_children import ChildrenDao - from custom_components.nestquest.dao_instances import ( + from custom_components.nestquest.core.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) - from custom_components.nestquest.dao_rules import ( + from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, ) @@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None: import inspect - from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_children import ChildrenDao methods = { name diff --git a/tests/test_complete_quest_proxy.py b/tests/test_complete_quest_proxy.py index 3aca922..4097b23 100644 --- a/tests/test_complete_quest_proxy.py +++ b/tests/test_complete_quest_proxy.py @@ -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 exactly the payload contract the panel cards send (``instance_id``, ``actor=panel``, ``actor_child_id``). The service performs NO local -database completion and fires NO local bus event — the API service -emits the transition on its SSE stream and the integration's -subscription re-fires it (firing it locally too would double-fire) — -and it refreshes the coordinator after a successful call so the -sensors reflect the completion immediately. Every failure (a typed -:class:`NestQuestApiError`, an unconfigured client, a non-panel -actor) surfaces as a :class:`HomeAssistantError`, token-free. +completion and fires NO local bus event — the API service emits the +transition on its SSE stream and the integration's subscription +re-fires it (firing it locally too would double-fire) — and it +refreshes the coordinator after a successful call so the sensors +reflect the completion immediately. Every failure (a typed +:class:`NestQuestApiError`, an unconfigured client, a non-panel actor) +surfaces as a :class:`HomeAssistantError`, token-free. The scripted clients here stand in for the API service: recording and failing ``complete_instance`` implementations, no network, no aiohttp. """ from __future__ import annotations -import datetime - import pytest from homeassistant.exceptions import HomeAssistantError -from conftest import ( - LazyLocalSnapshotClient, - set_coordinator_client, - 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.api_client import NestQuestApiError from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, CONF_PANEL_TOKEN, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, SERVICE_COMPLETE_QUEST, ) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule - -ADMIN_CTX = {"user_id": "admin-1"} -class _RecordingApiClient(LazyLocalSnapshotClient): +class _RecordingApiClient(ScriptedPanelClient): """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 prove the service handler itself completed nothing locally. """ - def __init__(self, hass, entry_id: str) -> None: - super().__init__(hass, entry_id) - self.complete_calls: list[tuple[int, int]] = [] - - async def complete_instance( - self, instance_id: int, actor_child_id: int - ) -> dict: - self.complete_calls.append((instance_id, actor_child_id)) - return {"status": "done"} - class _FailingApiClient(_RecordingApiClient): """A stand-in whose completion fails the way the client types it.""" - def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None: - super().__init__(hass, entry_id) - self._error = error - - async def complete_instance( - self, instance_id: int, actor_child_id: int - ) -> dict: - self.complete_calls.append((instance_id, actor_child_id)) - raise self._error + def __init__(self, error: NestQuestApiError, *outcomes) -> None: + super().__init__(*outcomes) + self.fail_complete_with = error -async def _setup_seeded_entry(hass, make_entry, client): - """Wire, script, and set up an entry with one child's daily quest.""" +def _empty_snapshot() -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": [], + } + + +async def _setup_entry(hass, make_entry, client): + """Wire, script, and set up an entry against the scripted client.""" entry = wire_entry_to_registry( - make_entry( - data={ - CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]], - CONF_PANEL_TOKEN: "secret-panel-token", - } - ), + make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}), hass.registry, ) set_coordinator_client(entry, client) assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - from custom_components.nestquest.children import create_child - - child = await create_child(database, "Ada") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [child.id], - ["morning"], - ) - end = (today + datetime.timedelta(days=3)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today.isoformat(), today.isoformat() - ) - assert instances - return entry, client, child, instances[0] + return entry def _record_refreshes(entry) -> list[bool]: @@ -133,11 +85,10 @@ async def test_complete_quest_reaches_the_shared_api_client( hass, make_entry ) -> None: """A panel tap calls ``complete_instance`` on the entry's shared - client with the tapped child, completes NOTHING locally, fires NO - local event, and refreshes the coordinator immediately.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, make_entry, _RecordingApiClient(hass, "test_entry") - ) + client with the tapped child, fires NO local event, and refreshes + the coordinator immediately.""" + client = _RecordingApiClient(_empty_snapshot()) + entry = await _setup_entry(hass, make_entry, client) assert entry.runtime_data.coordinator.api_client is 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, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": 1, }, ) - assert client.complete_calls == [(instance.id, child.id)] - # No local completion: the instance is still open in the local - # database — only the API service writes (through its own client). - from custom_components.nestquest.completion import instance_state - - assert ( - await instance_state(entry.runtime_data.database, instance.id) - == "open" - ) + assert client.complete_calls == [(7, 1)] # No local firing: the completed (and day-complete) events arrive # over the SSE stream, re-fired by the subscription — never from # this handler. @@ -198,13 +141,10 @@ async def test_api_failure_surfaces_as_a_service_error( ) -> None: """A typed API failure (non-2xx or transport) raises ``HomeAssistantError`` carrying the typed message — which never - includes the panel token — with nothing completed or fired - locally, and no refresh for a mutation that never happened.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, - make_entry, - _FailingApiClient(hass, "test_entry", error), - ) + includes the panel token — with nothing fired locally, and no + refresh for a mutation that never happened.""" + client = _FailingApiClient(error, _empty_snapshot()) + entry = await _setup_entry(hass, make_entry, client) refreshes = _record_refreshes(entry) with pytest.raises(HomeAssistantError, match=fragment) as excinfo: @@ -212,22 +152,16 @@ async def test_api_failure_surfaces_as_a_service_error( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": 1, }, ) # The typed error is chained, and the service error carries the # typed message only — never the panel token. assert excinfo.value.__cause__ is error assert "secret-panel-token" not in str(excinfo.value) - assert client.complete_calls == [(instance.id, child.id)] - from custom_components.nestquest.completion import instance_state - - assert ( - await instance_state(entry.runtime_data.database, instance.id) - == "open" - ) + assert client.complete_calls == [(7, 1)] assert hass.bus.fired(EVENT_QUEST_COMPLETED) == [] assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] assert refreshes == [] @@ -241,9 +175,7 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error( of reaching an attribute error.""" from custom_components.nestquest.coordinator import _UnconfiguredApiClient - entry, _client, child, instance = await _setup_seeded_entry( - hass, make_entry, _UnconfiguredApiClient() - ) + await _setup_entry(hass, make_entry, _UnconfiguredApiClient()) with pytest.raises( HomeAssistantError, match="NestQuest API is not configured" @@ -252,9 +184,9 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "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 user-actor call fails fast with a clear error and never reaches the client.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, make_entry, _RecordingApiClient(hass, "test_entry") - ) + client = _RecordingApiClient(_empty_snapshot()) + await _setup_entry(hass, make_entry, client) with pytest.raises(HomeAssistantError, match="actor must be 'panel'"): await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "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 == [] diff --git a/tests/test_completion.py b/tests/test_completion.py index 2b3cd02..d8b82a0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,27 +8,27 @@ import re import pytest -from custom_components.nestquest.completion import ( +from custom_components.nestquest.core.completion import ( append_event, complete_instance, instance_state, list_missed_for_child, uncomplete_instance, ) -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_instances import ( EVENT_COMPLETED, EVENT_UNCOMPLETED, CompletionEventRecord, CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None: def test_module_never_exposes_update_or_delete() -> None: - import custom_components.nestquest.completion as completion + import custom_components.nestquest.core.completion as completion public = { name diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a6ecc2d..d40aff7 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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}" ) -async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None: - """The HA user completing the flow lands in entry data as the - initial admin copy: entry.context is not persisted by real HA, so - the durable record must carry the owner.""" - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - flow = make_flow(hass) - flow.context = {"user_id": "ha-owner"} - result = await flow.async_step_user({}) - assert result["type"] == "create_entry" - assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]} - - async def test_flow_without_context_user_keeps_data_untouched( hass, make_flow ) -> None: + """A submit with user input stores it as-is; no admin copy is + invented (the allowlist lives in the API service's own store since + the DB removal).""" flow = make_flow(hass) - result = await flow.async_step_user({"horizon_days": 7}) + result = await flow.async_step_user({"update_interval": 60}) assert result["type"] == "create_entry" - assert result["data"] == {"horizon_days": 7} + assert result["data"] == {"update_interval": 60} diff --git a/tests/test_core_settings.py b/tests/test_core_settings.py index 67a4b9c..a9f04b9 100644 --- a/tests/test_core_settings.py +++ b/tests/test_core_settings.py @@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes: test feeds the scanner each bypass shape and asserts it flags them. - A settings object constructed with NON-default ``horizon_days`` and ``day_rollover_time`` drives a materialization whose resulting window - matches the non-default horizon — via BOTH the core ``materialize`` - path and the INTEGRATION path (``_run_horizon_materialization`` + - ``_register_day_rollover_listener``). + matches the non-default horizon — via the core ``materialize`` path + (the integration path it used to share is gone with the integration's + database). """ from __future__ import annotations @@ -29,7 +29,6 @@ from typing import Any import pytest import custom_components.nestquest.core as _core_mod -from conftest import freeze_nestquest_clock from custom_components.nestquest.const import ( CONF_AFTERNOON_REMINDER_ENABLED, @@ -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: 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() diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index f704bc6..541c4f6 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -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 dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and ``missed`` omission) are all exercised as literals. + +The database is a standalone core one (the integration owns no +database since Feature 18's DB removal); the closing round-trip pins +that the integration's coordinator reconstructs the SAME dataclasses +from the route's payload. """ from __future__ import annotations import datetime from zoneinfo import ZoneInfo -from conftest import 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.children import create_child -from custom_components.nestquest.completion import complete_instance +from custom_components.nestquest.core.children import create_child +from custom_components.nestquest.core.completion import complete_instance from custom_components.nestquest.core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, @@ -28,14 +33,17 @@ from custom_components.nestquest.core.snapshot import ( build_snapshot, instance_payload, ) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_instances import QuestInstancesDao +from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, 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.core.db import NestQuestDatabase +from custom_components.nestquest.core.materialize import materialize +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import create_quest_definition +from custom_components.nestquest.core.recurrence import ScheduleRule +from custom_components.nestquest.core.settings import NestQuestSettings 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. 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 before today puts ``cycle_day`` at 2. """ - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - settings = entry.runtime_data.settings + # A standalone core database (the integration owns none): the + # builder and its seed run against it through the executor. + database = NestQuestDatabase(hass.async_add_executor_job) + await database.open(tmp_path / "snapshot.db") + await apply_migrations(database) + settings = NestQuestSettings() # Single clock read for the seed/snapshot anchor (no midnight skew); # 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 # reconstruction that dropped or reshaped a field would diverge # here even though the literal assertions above still pass. - from conftest import set_coordinator_client - class _PinnedPayloadClient: async def get_snapshot(self): return { @@ -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()) - await coordinator.async_refresh() + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator assert coordinator.data == built + await database.close() diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index 0426169..a7097d0 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -6,15 +6,15 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ( +from custom_components.nestquest.core.dao_children import ( AdminUserRecord, AdminUsersDao, ChildRecord, ChildrenDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS +from custom_components.nestquest.core.migrations import apply_migrations 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_presence.py", # presence guard, own scope "tests/test_dao_instances.py", # instances guard, own scope - "tests/test_startup_db.py", # row-count probe of the children - # table only (proving a fresh setup loads); guard below covers - # everything else } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+" @@ -558,32 +555,6 @@ def _strip_function_spans(text: str, names: set[str]) -> str: for number, line in enumerate(lines, start=1) if number not in excluded ) - - -def test_startup_db_test_file_uses_dao_elsewhere() -> None: - """Compensating self-scan for the startup-test exemption: outside - the single sanctioned COUNT(*) probe, test_startup_db.py must go - through the DAO for children/admin_users data access. - """ - import re - from pathlib import Path - - startup_test = Path(__file__).parent / "test_startup_db.py" - stripped = _strip_function_spans( - startup_test.read_text(), - {"test_setup_with_valid_file_preserves_rows"}, - ) - sql_pattern = re.compile( - r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b", - re.IGNORECASE, - ) - assert sql_pattern.search(stripped) is None, ( - "test_startup_db.py must go through the DAO for these tables " - "outside its sanctioned row-count probe" - ) - - def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None: """Compensating self-scan for the matrix-file exemption, structurally exact: parse the two sanctioned functions' ASTs and require that diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index 1aef2bf..c30714b 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -7,16 +7,16 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_instances import ( CompletionEventRecord, CompletionEventsDao, QuestInstanceRecord, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 945471c..59961f5 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest.dao_children import ( +from custom_components.nestquest.core.dao_children import ( AdminUsersDao, ChildrenDao, ) -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, PresenceSchedulesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( ScheduleRulesDao, QuestDefinitionsDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 1b96116..8d4cbf6 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -6,15 +6,15 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_presence import ( PresenceOverrideRecord, PresenceOverridesDao, PresenceScheduleRecord, PresenceSchedulesDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: from unittest.mock import MagicMock async def _main(): - from custom_components.nestquest.dao_children import ChildrenDao - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations + from custom_components.nestquest.core.db import NestQuestDatabase + from custom_components.nestquest.core.migrations import apply_migrations armed = {"active": False, "gated": False} gate_open = asyncio_module.Event() diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index ad8bb7a..7047d86 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -6,8 +6,8 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_rules import ( ScheduleRuleRecord, ScheduleRuleStorage, ScheduleRulesDao, @@ -17,9 +17,9 @@ from custom_components.nestquest.dao_rules import ( schedule_rule_from_storage, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # delete task has been scheduled, so the create holds # the connection lock while the delete queues behind # it — then the create proceeds and wins. - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_validate = ( dao_rules.QuestDefinitionsDao._validate_rule_exists diff --git a/tests/test_db.py b/tests/test_db.py index c31ceb4..79b3e76 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -14,7 +14,7 @@ import pytest from conftest import executor_for, make_hass import custom_components.nestquest.core.db as db_module -from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.core.db import NestQuestDatabase 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 == {} \ No newline at end of file diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py index 48f9b84..4dd384f 100644 --- a/tests/test_entity_lifecycle.py +++ b/tests/test_entity_lifecycle.py @@ -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 -import datetime - -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.children import ( - create_child, - list_children, - set_child_active, -) -from custom_components.nestquest.const import DOMAIN -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule -SEED_HORIZON_DAYS = 3 PER_CHILD_KINDS = ( "quests_due_today", "quests_completed_today", @@ -34,6 +26,78 @@ HOUSEHOLD_UNIQUE_IDS = ( "nestquest_cycle_day", ) +ADA = 1 +BO = 2 +CORY_ID = 4 + + +def _child(child_id: int, name: str, *, present=True, instances=()): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": present, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + + +def _instance(instance_id: int, child_id: int, *, state="open") -> dict: + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00", + "on_time": None if state == "open" else True, + } + + +def _payload(children) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +#: The full household (Ada + Bo, one daily quest each) and the states +#: the lifecycle walks through: Bo deactivated, reactivated, completed. +_BOTH = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO)]), + ] +) +_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]) +_BO_DONE = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO, state="completed")]), + ] +) +_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]) +_ADA_AND_CORY = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(CORY_ID, "Cory"), + ] +) + + +def _child_unique_ids(child_id: int) -> set[str]: + return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} + async def _reload(hass, entry) -> None: """Unload then set up: the harness's restart/reload equivalent.""" @@ -41,46 +105,36 @@ async def _reload(hass, entry) -> None: assert await async_setup_entry(hass, entry) is True -def _child_unique_ids(child_id: int) -> set[str]: - return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} - - -async def _setup_and_seed(hass, make_entry): - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - 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()} +async def _setup_with_household(hass, make_entry, *payloads): + """Set up the entry with a scripted, ordered snapshot history.""" + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning"], + hass.registry, ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - await entry.runtime_data.coordinator.async_refresh() - return entry, ada, bo + client = set_coordinator_client(entry, StubSnapshotClient(*payloads)) + assert await async_setup_entry(hass, entry) is True + return entry, entry.runtime_data.coordinator, client async def test_deactivated_child_entities_removed_on_reload( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - assert _child_unique_ids(ada.id) <= set(hass.entities) - assert _child_unique_ids(bo.id) <= set(hass.entities) + """A child absent from the API's snapshot loses its entities on + reload; the surviving child's entities are untouched.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_GONE + ) + assert _child_unique_ids(ADA) <= set(hass.entities) + assert _child_unique_ids(BO) <= set(hass.entities) - await set_child_active(entry.runtime_data.database, bo.id, False) 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" ) - 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" ) @@ -88,100 +142,61 @@ async def test_deactivated_child_entities_removed_on_reload( async def test_reactivation_restores_entities_with_original_unique_ids( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - original = _child_unique_ids(bo.id) - await set_child_active(entry.runtime_data.database, bo.id, False) + original = _child_unique_ids(BO) + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_GONE, _BOTH + ) await _reload(hass, entry) assert not (original & set(hass.entities)) - await set_child_active(entry.runtime_data.database, bo.id, True) await _reload(hass, entry) assert original <= set(hass.entities), ( "reactivation must restore the exact original unique_ids" ) - bo_due = hass.entities[f"nestquest_child_{bo.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" async def test_new_child_gets_entities_without_disturbing_existing( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - before_ada = set(hass.entities) & _child_unique_ids(ada.id) + """A child ADDED to the API's snapshot gains its entities on + reload; the existing children's entities are untouched.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _ADA_ONLY, _ADA_AND_CORY + ) + before_ada = set(hass.entities) & _child_unique_ids(ADA) - cory = await create_child(entry.runtime_data.database, "Cory") await _reload(hass, entry) assert before_ada <= set(hass.entities), "existing entities untouched" - assert _child_unique_ids(cory.id) <= set(hass.entities), ( + assert _child_unique_ids(CORY_ID) <= set(hass.entities), ( "the new child's entities appear after reload" ) - assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0 + assert hass.entities[ + f"nestquest_child_{CORY_ID}_quests_due_today" + ].native_value == 0 async def test_restart_equivalent_restores_all_entity_states( hass, make_entry ) -> None: """Unload + setup (the restart equivalent) restores every active - child's entities with correct states and the household rollups.""" - from custom_components.nestquest.completion import complete_instance - - entry, ada, bo = await _setup_and_seed(hass, make_entry) - coordinator = entry.runtime_data.coordinator - snapshot_ada = next( - child - for child in coordinator.data.children - if child.child_id == ada.id + child's entities with the API's current states and the household + rollups.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_DONE ) - await complete_instance( - entry.runtime_data.database, - snapshot_ada.instances[0].instance_id, - actor_source="panel", - actor_child_id=ada.id, - ) - await coordinator.async_refresh() - assert hass.entities[ - f"nestquest_child_{ada.id}_quests_completed_today" - ].native_value == 1 - await _reload(hass, entry) assert hass.entities[ - f"nestquest_child_{ada.id}_quests_completed_today" - ].native_value == 1, "completed count survives the restart" - assert hass.entities[ - f"nestquest_child_{bo.id}_quests_completed_today" + f"nestquest_child_{ADA}_quests_completed_today" ].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 hass.entities[ "nestquest_household_quests_completed_today" ].native_value == 1 - - -async def test_unload_shuts_coordinator_before_closing_database( - hass, make_entry -) -> None: - """The coordinator's listeners are cleared while the database is - still connected: a scheduled refresh must never race the close.""" - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - seen_connected = [] - - async def _probe_shutdown(): - seen_connected.append(database.connected) - - original_close = database.close - - async def _recording_close(): - seen_connected.append("closing") - await original_close() - - coordinator.async_shutdown = _probe_shutdown - database.close = _recording_close - assert await async_unload_entry(hass, entry) is True - assert seen_connected == [True, "closing"], ( - "coordinator shutdown must precede the database close" - ) diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index f135bb6..dcd9497 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -2,74 +2,156 @@ One fixture drives every assertion the done-condition names: correct values for every sensor and binary sensor (including the zero-quest -child and the 255-character state limit), the three events firing -exactly once with schema-valid payloads, the instances payload -omitting missed instances while admin_instances includes them, and no -entities for an inactive child. The per-task test files cover each -behavior in depth; this file is the single pass that proves them all -against ONE fixture, so a regression in any contract fails here too. +child and the 255-character state limit), the completed/uncompleted/ +day-complete events re-fired exactly once from the API's SSE frames, +the panel payload omitting missed instances while admin_instances +equals it, and no entities for a child the API does not report. The +per-task test files cover each behavior in depth; this file is the +single pass that proves them all against ONE fixture, so a regression +in any contract fails here too. + +Since the DB removal the household state and every transition come +from the API service, so the fixture scripts the panel route's +payloads and completion surface (:class:`conftest.ScriptedPanelClient`) +and delivers the transition frames through the SSE subscription — the +production event path. """ from __future__ import annotations -import datetime 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.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 ( - CONF_ADMIN_USER_IDS, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, EVENT_QUEST_UNCOMPLETED, 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$") +ADA = 1 +BO = 2 +CORY = 3 + +_OCCURRED = "2026-09-23T07:55:00+00:00" + + +def _instance(instance_id: int, child_id: int, *, window="morning", state="open"): + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": window, + "due_time": "08:00" if window == "morning" else "19:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else _OCCURRED, + "on_time": None if state == "open" else True, + } + + +def _child(child_id: int, name: str, instances=()): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": True, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + + +def _payload(children, cycle_day=0) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": cycle_day, + "children": list(children), + } + + +def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str): + """The completed-event frame the API service publishes for one + panel completion (the day-complete rule is the API's; its payload + shape is pinned by tests/test_api_sse.py on the production + plane).""" + return ( + EVENT_QUEST_COMPLETED, + { + "child_id": child_id, + "child_name": child_name, + "instance_id": instance_id, + "quest_title": "Brush teeth", + "window": window, + "due_date": "2026-09-23", + "due_time": "08:00" if window == "morning" else "19:00", + "occurred_at": _OCCURRED, + "was_on_time": True, + }, + ) + + +def _day_complete_frame(child_id: int, child_name: str): + """The day-complete frame the API publishes once a child's day + clears (after the LAST completion of the day).""" + return ( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": child_id, + "child_name": child_name, + "quests_due": 2, + "quests_completed": 2, + "occurred_at": _OCCURRED, + }, + ) + async def _fixture(hass, make_entry): - """Two quest children, one zero-quest child, 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( - make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), 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") - 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()} + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning", "evening"], + hass.registry, ) - end = (today + datetime.timedelta(days=3)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - await entry.runtime_data.coordinator.async_refresh() - return entry, (ada, bo, cory, ghost) + client = ScriptedPanelClient( + _payload( + [ + _child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]), + _child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]), + _child(CORY, "Cory"), + ] + ), + _payload( + [ + _child( + ADA, + "Ada", + [ + _instance(7, ADA, state="completed"), + _instance(8, ADA, window="evening", state="completed"), + ], + ), + _child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]), + _child(CORY, "Cory"), + ] + ), + ) + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + return entry, client 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: - entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry) + entry, client = await _fixture(hass, make_entry) coordinator = entry.runtime_data.coordinator - # --- Every active child has the full entity set; the inactive - # child has none. + # --- Every reported child has the full entity set; the child the + # API does not report has none. kinds = ( "quests_due_today", "quests_completed_today", @@ -91,36 +173,37 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: "all_done", "present_today", ) - for child in (ada, bo, cory): + ghost_id = 99 + for child in (ADA, BO, CORY): 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: - assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, ( - "inactive children must have no entities" + assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, ( + "children absent from the API snapshot must have no entities" ) # --- Correct values against the fixture: Ada and Bo owe two # quests (morning + evening), Cory owes none and is still 100%. - for child in (ada, bo): - assert _sensor(hass, child.id, "quests_due_today").native_value == 2 + for child in (ADA, BO): + assert _sensor(hass, child, "quests_due_today").native_value == 2 assert ( - _sensor(hass, child.id, "quests_completed_today").native_value == 0 + _sensor(hass, child, "quests_completed_today").native_value == 0 ) assert ( - _sensor(hass, child.id, "quests_remaining_today").native_value == 2 + _sensor(hass, child, "quests_remaining_today").native_value == 2 ) 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" ) - assert _sensor(hass, child.id, "all_done").is_on is False - assert _sensor(hass, child.id, "present_today").is_on is True - assert _sensor(hass, cory.id, "quests_due_today").native_value == 0 - assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100 - assert _sensor(hass, cory.id, "all_done").is_on is False - assert _sensor(hass, cory.id, "next_quest").native_value == "none" + assert _sensor(hass, child, "all_done").is_on is False + assert _sensor(hass, child, "present_today").is_on is True + assert _sensor(hass, CORY, "quests_due_today").native_value == 0 + assert _sensor(hass, CORY, "completion_pct_today").native_value == 100 + assert _sensor(hass, CORY, "all_done").is_on is False + assert _sensor(hass, CORY, "next_quest").native_value == "none" # --- Household rollups equal the per-child sums. assert hass.entities[ @@ -140,27 +223,31 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: ), unique_id # --- Completing both of Ada's quests fires the events exactly - # once each and flips her entities immediately. - instances = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range( - ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + # once each and flips her entities immediately. The transition + # events arrive over the API's SSE stream (the subscription + # re-fires them), never from the service handler. + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": 7, "actor": "panel", "actor_child_id": ADA}, ) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": ada.id, - }, - ) - # The transition events arrive over the API's SSE stream (the - # subscription re-fires them), never from the service handler. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": 8, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.extend( + [ + _completed_frame(7, ADA, "Ada", "morning"), + _completed_frame(8, ADA, "Ada", "evening"), + # The API publishes the day-complete frame only when the + # day clears — after the LAST completion of the day. + _day_complete_frame(ADA, "Ada"), + ] + ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2 for payload in hass.bus.fired(EVENT_QUEST_COMPLETED): assert _TIMESTAMP.match(payload["occurred_at"]) @@ -169,71 +256,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: assert payload["quest_title"] == "Brush teeth" assert isinstance(payload["instance_id"], int) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 - assert _sensor(hass, ada.id, "all_done").is_on is True - assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0 + # The next scripted snapshot reflects both completions. + await coordinator.async_refresh() + assert _sensor(hass, ADA, "all_done").is_on is True + assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0 assert ( - _sensor(hass, ada.id, "completion_pct_today").native_value == 100 + _sensor(hass, ADA, "completion_pct_today").native_value == 100 ) - # --- Reversing one quest fires the uncompleted event exactly once - # and restores the owed counts. The HA ``uncomplete_quest`` - # service is gone (the API service's admin route is the reversal - # path): drive the core completion layer it calls, publish the - # uncompleted transition the way the API does, and deliver it - # through the SSE subscription. - reversal = await uncomplete_instance( - entry.runtime_data.database, - instances[0].id, - actor_source="user", - actor_user_id="admin-1", - ) - assert reversal.appended is True - client = coordinator.api_client - client.published_frames.extend( - await build_quest_uncompleted_events( - entry.runtime_data.database, instances[0].id + # --- Reversing one quest fires the uncompleted event exactly once. + # The HA ``uncomplete_quest`` service is gone (the API service's + # admin route is the reversal path), so the reversal is the API's: + # it publishes the uncompleted frame and the integration re-fires + # it; the coordinator's next poll picks the restored counts up. + client.published_frames.append( + ( + EVENT_QUEST_UNCOMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "instance_id": 7, + "quest_title": "Brush teeth", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "08:00", + "occurred_at": _OCCURRED, + }, ) ) await refire_api_transitions(hass, entry, client) 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() - assert _sensor(hass, ada.id, "all_done").is_on is False - assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 - - # --- The instances payload omits missed instances (D-009) — and - # since Feature 18 the snapshot comes from the API panel route, - # which omits missed rows ENTIRELY, so admin_instances cannot - # carry them either (the admin surface for missed quests is the - # PWA, reading the API directly): pin the derive clock forward in - # the TEST ONLY so today's open instances read missed. - original_refresh = coordinator._async_update_data - - async def _future_dated_refresh(): - import custom_components.nestquest.core.snapshot as snapshot_module - - real_derive = snapshot_module.derive_state - - def _derive(instance, latest, today): - return real_derive(instance, latest, today + datetime.timedelta(days=1)) - - snapshot_module.derive_state = _derive - try: - return await original_refresh() - finally: - snapshot_module.derive_state = real_derive - - coordinator._async_update_data = _future_dated_refresh - await coordinator.async_refresh() - attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes - assert attributes["instances"] == [], "panel payload omits missed" - assert attributes["admin_instances"] == [], ( + attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes + assert attributes["instances"] == [ + dict(_instance(9, BO)), + dict(_instance(10, BO, window="evening")), + ], "the panel payload round-trips the API's open rows verbatim" + assert attributes["admin_instances"] == attributes["instances"], ( "the API payload omits missed rows, so the admin payload " "carries none either (the PWA is the admin surface now)" ) - coordinator._async_update_data = original_refresh - await coordinator.async_refresh() async def test_payload_schema_matches_the_documented_contract( @@ -241,25 +310,16 @@ async def test_payload_schema_matches_the_documented_contract( ) -> None: """Every fired payload carries the documented fields with the documented types.""" - entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry) - instances = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range( - ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() - ) + entry, client = await _fixture(hass, make_entry) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "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 + {"instance_id": 7, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning")) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0] for key, check in ( ("child_id", lambda v: isinstance(v, int)), diff --git a/tests/test_events.py b/tests/test_events.py index 160bd07..ac45112 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,31 +1,25 @@ """Tests for the NestQuest bus events (Feature 10, task 6). -Since Feature 18 the integration is a client of the NestQuest API -service: ``nestquest.complete_quest`` proxies to the API's panel -complete route and fires NO local bus event — the API service builds -and publishes the transition frames on its SSE stream, and the -integration's subscription (:mod:`custom_components.nestquest.sse`) -re-fires them. The service-driven tests below therefore deliver the -frames through :func:`conftest.refire_api_transitions` (the -production event path, driven synchronously) before asserting the -bus; the shim-level tests drive :mod:`custom_components.nestquest -.events` directly, the way its remaining callers do. The HA -``uncomplete_quest`` service is also gone (the API service's admin -uncomplete route is the reversal path), so the reversal tests drive -the core completion layer the route calls and deliver the published -frame through the same SSE subscription helper. +Since Feature 18 — and, since the DB removal, without any local +business layer — the integration fires NO transition event itself: +``nestquest.complete_quest`` proxies to the API's panel complete +route, the API service builds and publishes the transition frames on +its SSE stream, and the integration's subscription +(:mod:`custom_components.nestquest.sse`) re-fires them. The tests +below script the API's payloads and frames with +:class:`conftest.ScriptedPanelClient` and deliver them through +:func:`conftest.refire_api_transitions` (the production event path, +driven synchronously) before asserting the bus. The API's own frame +construction is pinned on the production plane by tests/test_api_sse.py. """ from __future__ import annotations 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.children import create_child -from custom_components.nestquest.completion import uncomplete_instance from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, @@ -33,75 +27,129 @@ from custom_components.nestquest.const import ( EVENT_QUEST_UNCOMPLETED, 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$") +ADA = 1 +_OCCURRED = "2026-09-23T07:55:00+00:00" -async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None): - """Seed a household and materialize today's instances. - ``seed_date`` pins the date instances are materialized for (and the - daily rule's ``start_date``); it defaults to the host's - ``datetime.date.today()``. Callers that need to test the day-complete - rule against a non-UTC HA-local timezone pass the local zone's - ``today`` here so the seeded instance's ``due_date`` matches it. - """ - import datetime +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, + } - if seed_date is None: - seed_date = datetime.date.today() + +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) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +def _open_payload(instance_count: int) -> dict: + return _payload( + [ + _child( + ADA, + "Ada", + [_instance(10 + offset, ADA) for offset in range(instance_count)], + ) + ] + ) + + +def _completed_frame(instance_id: int, *, window="morning"): + return ( + EVENT_QUEST_COMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "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(quests_due: int): + return ( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": ADA, + "child_name": "Ada", + "quests_due": quests_due, + "quests_completed": quests_due, + "occurred_at": _OCCURRED, + }, + ) + + +async def _setup_entry(hass, make_entry, client): + """Wire, script, and set up an entry serving the scripted household.""" entry = wire_entry_to_registry( - make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - child = await create_child(database, "Ada") - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": seed_date.isoformat()} + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [child.id], - list(windows), + hass.registry, ) - today_iso = seed_date.isoformat() - end_iso = (seed_date + datetime.timedelta(days=3)).isoformat() - await materialize(database, today_iso, end_iso, today=seed_date) - instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today_iso, today_iso - ) - return entry, child, instances + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + return entry async def test_quest_completed_fires_with_documented_payload( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) # The completed event arrives over the API's SSE stream (the # subscription re-fires it), never from the service handler. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) fired = hass.bus.fired(EVENT_QUEST_COMPLETED) assert len(fired) == 1 payload = fired[0] - assert payload["child_id"] == child.id + assert payload["child_id"] == ADA assert payload["child_name"] == "Ada" assert payload["instance_id"] == instance_id assert payload["quest_title"] == "Brush teeth" @@ -111,26 +159,25 @@ async def test_quest_completed_fires_with_documented_payload( async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) # The no-op verdict is decided in the API's completion layer: the # second call appended nothing, so the API published no second # frame and the bus stays at exactly one event. - client = entry.runtime_data.coordinator.api_client - assert [result.appended for _id, result in client.completions] == [ - True, - False, - ] + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 @@ -138,92 +185,101 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: async def test_child_day_complete_fires_when_day_clears( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.extend( + [ + _completed_frame(instance_id), + _day_complete_frame(quests_due=1), + ] + ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) assert len(day_complete) == 1, "exactly one day-complete per cleared day" payload = day_complete[0] - assert payload["child_id"] == child.id + assert payload["child_id"] == ADA assert payload["child_name"] == "Ada" - assert payload["quests_due"] == len(instances) + assert payload["quests_due"] == 1 assert _TIMESTAMP.match(payload["occurred_at"]) async def test_child_day_complete_does_not_fire_partway( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 + client = ScriptedPanelClient(_open_payload(2)) + entry = await _setup_entry(hass, make_entry, client) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 10, "actor": "panel", "actor_child_id": ADA}, ) # The API publishes the completion but not a day-complete (the # child's day is not clear yet); the subscription re-fires only # what was published. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) + client.published_frames.append(_completed_frame(10)) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[1].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 11, "actor": "panel", "actor_child_id": ADA}, ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + client.published_frames.extend( + [ + _completed_frame(11, window="evening"), + _day_complete_frame(quests_due=2), + ] ) + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: """An actual reversal fires the uncompleted event — but only once, and only through the API service's transition stream: the HA - ``uncomplete_quest`` service is gone, so the reversal is driven - through the core completion layer the API's admin uncomplete route - calls, with the published frame delivered by the SSE subscription.""" - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id - database = entry.runtime_data.database - client = entry.runtime_data.coordinator.api_client + ``uncomplete_quest`` service is gone, so the reversal is the API's + admin route; its published frame is delivered by the SSE + subscription.""" + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) - # The reversal appends through the core completion layer; the API - # route publishes the transition on its SSE stream and the - # subscription re-fires it on the bus. - result = await uncomplete_instance( - database, instance_id, actor_source="user", actor_user_id="admin-1" - ) - assert result.appended is True client.published_frames.extend( - await build_quest_uncompleted_events(database, instance_id) + [ + _completed_frame(instance_id), + # The API's admin uncomplete route publishes the reversal + # the same way the panel completion publishes its event. + ( + EVENT_QUEST_UNCOMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "instance_id": instance_id, + "quest_title": "Brush teeth", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "08:00", + "occurred_at": _OCCURRED, + }, + ), + ] ) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED) assert len(fired) == 1 @@ -233,10 +289,6 @@ async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: assert _TIMESTAMP.match(payload["occurred_at"]) # Un-completing an already-open instance is a no-op: nothing is # appended, nothing is published, and nothing further fires. - no_op = await uncomplete_instance( - database, instance_id, actor_source="user", actor_user_id="admin-1" - ) - assert no_op.appended is False await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 @@ -249,12 +301,15 @@ def test_missed_event_name_is_documented_contract() -> None: async def test_appended_verdict_is_atomic_under_duplicate_calls( hass, make_entry ) -> None: - """Concurrent duplicate completes decide appended under the same - lock as the write: exactly one fires the transition event.""" + """Concurrent duplicate completes each reach the API, whose + completion layer decides the appended verdict: exactly one + appended, so the API published exactly one frame and the bus + carries one event.""" import asyncio - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 calls = [ hass.services.call( DOMAIN, @@ -262,139 +317,60 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls( { "instance_id": instance_id, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": ADA, }, ) for _ in range(2) ] await asyncio.gather(*calls) - # The API's completion layer (the same core lock) decides the - # verdict: one call appended, the duplicate did not — so the API - # published exactly one frame and the bus carries one event. - client = entry.runtime_data.coordinator.api_client - assert len(client.completions) == 2 - assert [result.appended for _id, result in client.completions].count( - True - ) == 1 + assert len(client.complete_calls) == 2 + # The API's completion layer decides the verdict: one call + # appended, the duplicate did not — so the API published exactly + # one frame and the bus carries one event. + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 -async def test_day_complete_ignored_for_backdated_completion( - hass, make_entry -) -> None: - """Completing an instance NOT due today never evaluates the - child's day-complete, so a cleared day is announced at most once - per day and only by that day's completions.""" - import datetime - - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # A future instance (tomorrow) completed after today's day is - # already clear. - today = datetime.date.today() - end = (today + datetime.timedelta(days=3)).isoformat() - all_instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today.isoformat(), end - ) - today_ids = {instance.id for instance in instances} - tomorrow_instance = next( - instance - for instance in all_instances - if instance.id not in today_ids - ) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 - - await _complete( - database, - tomorrow_instance.id, - actor_source="user", - actor_user_id="admin-1", - ) - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": tomorrow_instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - # The service call on the already-done future instance is a no-op - # inside the API's completion layer: nothing appended, nothing - # published, nothing re-fired — today's single announcement stands. - client = entry.runtime_data.coordinator.api_client - assert client.completions[-1][1].appended is False - await refire_api_transitions(hass, entry, client) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, ( - "a back-dated/future completion must not re-announce today" - ) - # The direct business-layer call published nothing (only the API's - # completion path publishes), and the follow-up service call on - # the now-done future instance was a no-op: today's single - # completion stands. - assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances) - - async def test_completion_updates_entities_immediately( hass, make_entry ) -> None: """The remaining-today sensor reflects the completion the moment the service call returns — no manual refresh — and the day-complete event fired exactly once when the day became clear.""" - from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE - - entry, child, instances = await _setup_and_seed(hass, make_entry) - from conftest import wire_entry_to_registry # noqa: F401 - - coordinator = entry.runtime_data.coordinator - # Entities only exist after one refresh since they were seeded - # after setup; create them, then prove the service path refreshes. - await coordinator.async_refresh() + client = ScriptedPanelClient( + _open_payload(1), + _payload([_child(ADA, "Ada", [_instance(10, ADA, state="completed")])]), + ) + entry = await _setup_entry(hass, make_entry, client) remaining_before = hass.entities[ - f"nestquest_child_{child.id}_quests_remaining_today" + f"nestquest_child_{ADA}_quests_remaining_today" ].native_value - assert remaining_before == len(instances) + assert remaining_before == 1 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 10, "actor": "panel", "actor_child_id": ADA}, ) remaining_after = hass.entities[ - f"nestquest_child_{child.id}_quests_remaining_today" + f"nestquest_child_{ADA}_quests_remaining_today" ].native_value - assert remaining_after == len(instances) - 1, ( + assert remaining_after == 0, ( "sensor must reflect the completion without a manual refresh" ) - # The transition events arrive over the API's SSE stream. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == ( - 1 if len(instances) == 1 else 0 + client.published_frames.extend( + [ + _completed_frame(10), + _day_complete_frame(quests_due=1), + ] ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 async def test_forced_refreshes_serialize_no_stale_publish( @@ -405,7 +381,8 @@ async def test_forced_refreshes_serialize_no_stale_publish( it, so the last published snapshot reflects the latest mutation.""" import asyncio - entry, child, instances = await _setup_and_seed(hass, make_entry) + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) coordinator = entry.runtime_data.coordinator order: list[str] = [] @@ -420,465 +397,3 @@ async def test_forced_refreshes_serialize_no_stale_publish( assert order == ["start", "end", "start", "end"], ( "overlapping refreshes must serialize: " + str(order) ) - - -# --------------------------------------------------------------------------- -# Direct core-builder unit tests (P2.2a): exact payload key sets, event -# order, and was_on_time threading. These target -# custom_components.nestquest.core.events directly, bypassing the shim, -# so a payload-shape regression is caught even if the shim masks it. -# --------------------------------------------------------------------------- - -_COMPLETED_KEYS = { - "child_id", - "child_name", - "instance_id", - "quest_title", - "window", - "due_date", - "due_time", - "occurred_at", - "was_on_time", -} -_UNCOMPLETED_KEYS = { - "child_id", - "child_name", - "instance_id", - "quest_title", - "window", - "due_date", - "due_time", - "occurred_at", -} -_DAY_COMPLETE_KEYS = { - "child_id", - "child_name", - "quests_due", - "quests_completed", - "occurred_at", -} - - -async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None: - """The completed-event payload carries exactly the documented keys - (no extra fields, no missing field) and threads ``was_on_time``.""" - from custom_components.nestquest.core.events import build_quest_completed_event - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - event_type, payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - assert event_type == EVENT_QUEST_COMPLETED - assert set(payload) == _COMPLETED_KEYS, ( - f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}" - ) - assert payload["was_on_time"] is True - assert payload["child_id"] == child.id - assert payload["child_name"] == "Ada" - assert payload["instance_id"] == instances[0].id - assert payload["quest_title"] == "Brush teeth" - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] - - -async def test_build_quest_completed_event_was_on_time_threads_all_shapes( - hass, make_entry -) -> None: - """``was_on_time`` is carried verbatim — True, False, and None - (the unknown-timing shape) all round-trip into the payload.""" - from custom_components.nestquest.core.events import build_quest_completed_event - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - for wanted in (True, False, None): - _etype, payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=wanted - ) - assert payload["was_on_time"] is wanted, ( - f"was_on_time={wanted!r} did not thread through" - ) - - -async def test_build_quest_uncompleted_events_payload_key_set( - hass, make_entry -) -> None: - """The uncompleted-event payload carries exactly the documented keys - (no ``was_on_time`` — that field is completion-only).""" - from custom_components.nestquest.core.events import ( - build_quest_uncompleted_events, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - events = await build_quest_uncompleted_events(database, instances[0].id) - assert len(events) == 1 - event_type, payload = events[0] - assert event_type == EVENT_QUEST_UNCOMPLETED - assert set(payload) == _UNCOMPLETED_KEYS, ( - f"uncompleted payload keys drift: got {set(payload)} " - f"expected {_UNCOMPLETED_KEYS}" - ) - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] - - -async def test_build_child_day_complete_event_payload_key_set( - hass, make_entry -) -> None: - """The day-complete payload carries exactly the documented keys - when the child's whole day clears, and reuses the completed - event's ``occurred_at`` stamp (one transition, one timestamp).""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - completed_type, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - # Mark every today-instance done so the day clears. - from custom_components.nestquest.completion import complete_instance - - for instance in instances: - await complete_instance( - database, - instance.id, - actor_source="panel", - actor_child_id=_child.id, - today=today, - ) - day_event = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert day_event is not None - event_type, day_payload = day_event - assert event_type == EVENT_CHILD_DAY_COMPLETE - assert set(day_payload) == _DAY_COMPLETE_KEYS, ( - f"day-complete payload keys drift: got {set(day_payload)} " - f"expected {_DAY_COMPLETE_KEYS}" - ) - assert day_payload["occurred_at"] == completed_payload["occurred_at"], ( - "day-complete must reuse the completed event's occurred_at stamp" - ) - assert day_payload["quests_due"] == len(instances) - assert day_payload["quests_completed"] == len(instances) - - -async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None: - """The shim fires ``nestquest_quest_completed`` BEFORE - ``nestquest_child_day_complete`` (the pre-extraction ordering), so - a failure in the day-complete DB reads cannot suppress the - completed event. Asserted by bus event ORDER, not just counts. - - Driven directly: since Feature 18 the completion service proxies - to the API (whose event ordering is tested in test_api_sse.py); - this targets the shim module itself, the way its remaining - callers invoke it. - """ - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - from custom_components.nestquest.events import fire_quest_completed - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - for instance in instances: - result = await _complete( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - ) - await fire_quest_completed( - hass, - database, - instance.id, - was_on_time=result.was_on_time, - ) - ordered = [ - etype for etype, _payload in hass.bus.events - if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE) - ] - # The completed event must precede the day-complete event. - first_completed = ordered.index(EVENT_QUEST_COMPLETED) - first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE) - assert first_completed < first_day, ( - f"completed must fire before day-complete; order was {ordered}" - ) - - -# --------------------------------------------------------------------------- -# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the -# four outcomes — zero-quest day, non-today instance, partial day, and -# fully-cleared today. -# --------------------------------------------------------------------------- - - -async def test_day_complete_zero_quest_day_yields_no_event( - hass, make_entry -) -> None: - """A zero-quest day never fires day-complete: when the completed - payload's ``due_date`` matches ``today`` but the child has NO - instances due that date, the builder returns ``None`` (the - all-done binary sensor's rule — a day with zero quests owed can - never be "cleared").""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # A date the daily rule does NOT cover (well past the materialized - # horizon) — zero instances due this date. - zero_quest_day = datetime.date.today() + datetime.timedelta(days=365) - _etype, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - # Override due_date to the zero-quest day so the builder evaluates - # it (otherwise the due_date != today short-circuit fires first). - crafted = dict(completed_payload, due_date=zero_quest_day.isoformat()) - result = await build_child_day_complete_event( - database, crafted, today=zero_quest_day - ) - assert result is None, ( - "a zero-quest day must not yield a day-complete event" - ) - - -async def test_day_complete_non_today_instance_yields_no_event( - hass, make_entry -) -> None: - """Completing an instance NOT due today never evaluates the - child's day-complete for today: only the completed event fires.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # Complete a FUTURE instance (tomorrow), pass today = today. - from custom_components.nestquest.dao_instances import QuestInstancesDao - - today = datetime.date.today() - end = (today + datetime.timedelta(days=3)).isoformat() - all_instances = await QuestInstancesDao(database).list_by_date_range( - _child.id, today.isoformat(), end - ) - today_ids = {i.id for i in instances} - tomorrow_instance = next(i for i in all_instances if i.id not in today_ids) - _etype, completed_payload = await build_quest_completed_event( - database, tomorrow_instance.id, was_on_time=True - ) - assert completed_payload["due_date"] != today.isoformat() - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is None, ( - "a non-today instance must not yield a day-complete event for today" - ) - - -async def test_day_complete_partial_day_yields_no_event( - hass, make_entry -) -> None: - """A partial day (some quests still open) yields only the - completed event — day-complete does not fire until the WHOLE day - clears.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - from custom_components.nestquest.completion import complete_instance - - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 - database = entry.runtime_data.database - today = datetime.date.today() - # Complete ONLY the morning instance — the evening one stays open. - await complete_instance( - database, - instances[0].id, - actor_source="panel", - actor_child_id=child.id, - today=today, - ) - _etype, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is None, ( - "a partial day (evening quest still open) must not fire day-complete" - ) - - -async def test_day_complete_fully_cleared_today_yields_event( - hass, make_entry -) -> None: - """A fully-cleared today (all owed quests done) yields the - completed event AND the child_day_complete event.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - from custom_components.nestquest.completion import complete_instance - - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 - database = entry.runtime_data.database - today = datetime.date.today() - for instance in instances: - await complete_instance( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - today=today, - ) - # The last completion's payload drives the day-complete evaluation. - _etype, completed_payload = await build_quest_completed_event( - database, instances[-1].id, was_on_time=True - ) - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is not None, ( - "a fully-cleared today must yield the day-complete event" - ) - assert result[0] == EVENT_CHILD_DAY_COMPLETE - assert result[1]["quests_due"] == len(instances) - assert result[1]["quests_completed"] == len(instances) - - -# --------------------------------------------------------------------------- -# Shim timezone wiring (P2.2c): the conftest fake hass defaults -# hass.config.time_zone to UTC, so the day-complete rule's HA-local -# ``today`` wiring is otherwise untested. This test sets the zone to -# one whose current local date differs from UTC and asserts the -# day-complete rule uses THAT local date, not UTC. -# --------------------------------------------------------------------------- - - -async def test_shim_day_complete_uses_ha_local_timezone( - hass, make_entry -) -> None: - """The shim's day-complete rule reads ``hass.config.time_zone`` - and derives ``today`` in HA-local time, not UTC. - - Picks a zone whose current local date differs from UTC, seeds an - instance for THAT zone's today (not UTC's today), fires the shim - directly for a completion of that instance, and asserts - ``nestquest_child_day_complete`` fires — proving the local date - reached the day-complete rule. If no candidate zone has a - different date right now (only possible inside the ~1h window - around 12:00 UTC when all zones share one date), the test skips - rather than run vacuously. - - Driven directly: since Feature 18 the completion service proxies - to the API (whose day-complete ``today`` is the API host's local - date, tested in test_api_sse.py); this targets the shim module - itself, the way its remaining callers invoke it. - """ - import datetime as _dt - from zoneinfo import ZoneInfo - - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - from custom_components.nestquest.events import fire_quest_completed - - utc_today = _dt.datetime.now(_dt.timezone.utc).date() - far_zone = None - far_today = None - for tz_name in ( - "Pacific/Kiritimati", # +14 (UTC+14) - "Pacific/Auckland", # +12/+13 - "Pacific/Chatham", # +12:45/+13:45 - "Pacific/Honolulu", # -10 - "America/Los_Angeles", # -8/-7 - "Etc/GMT+12", # -12 - "Etc/GMT-13", # +13 - ): - local_today = _dt.datetime.now(ZoneInfo(tz_name)).date() - if local_today != utc_today: - far_zone = tz_name - far_today = local_today - break - if far_zone is None: - import pytest - - pytest.skip( - "no candidate zone has a date different from UTC right now" - ) - - hass.config.time_zone = far_zone - entry, child, instances = await _setup_and_seed( - hass, make_entry, seed_date=far_today - ) - database = entry.runtime_data.database - for instance in instances: - result = await _complete( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - ) - await fire_quest_completed( - hass, - database, - instance.id, - was_on_time=result.was_on_time, - ) - day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) - assert len(day_complete) == 1, ( - f"day-complete must fire under {far_zone} (local today " - f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); " - f"got {len(day_complete)} events" - ) - - -async def test_build_quest_missed_event_payload_key_set( - hass, make_entry -) -> None: - """The missed-event payload carries exactly the documented keys — - the same shape the nightly sweep hand-builds (§3), with no - ``was_on_time`` (completion-only).""" - from custom_components.nestquest.core.events import ( - build_quest_missed_event, - ) - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - event_type, payload = await build_quest_missed_event( - database, instances[0].id - ) - assert event_type == EVENT_QUEST_MISSED - assert event_type == "nestquest_quest_missed" - assert set(payload) == _UNCOMPLETED_KEYS, ( - f"missed payload keys drift: got {set(payload)} " - f"expected {_UNCOMPLETED_KEYS}" - ) - assert payload["child_id"] == child.id - assert payload["child_name"] == "Ada" - assert payload["instance_id"] == instances[0].id - assert payload["quest_title"] == "Brush teeth" - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index a0f39e7..3bfbac9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -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.const import ( - CONF_DAY_ROLLOVER_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_TIME, - DEFAULT_HORIZON_DAYS, - SERVICE_COMPLETE_QUEST, 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 -async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None: - entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) +async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) await async_setup_entry(hass, entry) runtime = entry.runtime_data assert runtime.entry_id == entry.entry_id - assert runtime.settings.horizon_days == 7 + assert runtime.coordinator is not None assert callable(runtime.remove_update_listener) +async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None: + """The integration owns no database any more: the daily + day-rollover/materialization listener is gone (the API service + owns the horizon), so setup registers NOTHING against the + time-change listener bookkeeping.""" + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert hass.time_change.size == 0 + + async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None: registry = hass.registry entry = _wire(make_entry(), registry) @@ -112,7 +118,7 @@ async def test_options_update_triggers_reload_once(hass, make_entry) -> None: registry = hass.registry entry = _wire(make_entry(), registry) await async_setup_entry(hass, entry) - entry.options = {"horizon_days": 30} + entry.options = {"update_interval": 60} await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] @@ -190,6 +196,16 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None: assert set(hass.data) == {DOMAIN_CONST} +async def test_setup_registers_the_completion_service(hass, make_entry) -> None: + """The one domain-global service registers with setup and deregisters + on the last unload.""" + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) + await async_unload_entry(hass, entry) + assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) + + async def test_setup_awaitable_listener_removal_supported() -> None: """A coroutine-returning remover is awaited during unload.""" removed = [] @@ -208,9 +224,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None: assert entry.runtime_data is None -async def test_setup_closes_database_when_listener_registration_fails() -> None: - """A listener-registration failure must not leak the opened database.""" - from custom_components.nestquest.db import NestQuestDatabase +async def test_setup_unwinds_when_listener_registration_fails() -> None: + """A listener-registration failure must not leak partial setup state.""" + from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST class _Boom(Exception): pass @@ -220,13 +236,16 @@ async def test_setup_closes_database_when_listener_registration_fails() -> None: hass, _registry = make_hass() with pytest.raises(_Boom): 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 hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST) -async def test_unload_closes_database_even_when_listener_removal_raises() -> None: - """Unload removes the listener, closes the DB in a finally, then raises.""" - from custom_components.nestquest.db import NestQuestDatabase +async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None: + """A raising update-listener remover must not skip the coordinator + shutdown: the retained error is re-raised only after the teardown + ran and the runtime record was cleared.""" + from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST class _RemoveBoom(Exception): pass @@ -238,383 +257,17 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non entry.add_update_listener = lambda listener: _remover hass, _registry = make_hass() 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): 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 DOMAIN not in hass.data - assert hass.time_change.size == 0, "time-change listener leaked" - - -async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None: - """A raising update-listener remover must not skip the time-change cancel. - - Both removers are independent: the update-listener remover raising must - still cancel the daily time-change callback, whose action would otherwise - fire after unload against a closed database. - """ - - class _RemoveBoom(Exception): - pass - - def _remover(): - raise _RemoveBoom() - - entry = make_config_entry(entry_id="update-boom-time-cancel") - entry.add_update_listener = lambda listener: _remover - hass, _registry = make_hass() - await async_setup_entry(hass, entry) - assert hass.time_change.size == 1 - with pytest.raises(_RemoveBoom): - await async_unload_entry(hass, entry) - assert hass.time_change.size == 0, "time-change listener not cancelled" - assert entry.runtime_data is None - assert DOMAIN not in hass.data - - -async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None: - """Setup registers a daily rollover listener and stores its cancel fn.""" - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - assert hass.time_change.size == 1 - registration = hass.time_change.registrations[0] - assert registration["hour"] == 0 - assert registration["minute"] == 0 - assert callable(entry.runtime_data.remove_time_change_listener) - - -async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None: - """The listener fires at the entry option's rollover time, not the default.""" - entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry) - await async_setup_entry(hass, entry) - registration = hass.time_change.registrations[0] - assert registration["hour"] == 6 - assert registration["minute"] == 30 - - -async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None: - """Unload removes the time-change listener alongside the update listener.""" - entry = _wire(make_entry(), hass.registry) - await async_setup_entry(hass, entry) - assert hass.time_change.size == 1 - assert await async_unload_entry(hass, entry) is True + assert DOMAIN_CONST not in hass.data 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") \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e774f75..e9df73a 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -6,29 +6,29 @@ import datetime 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.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_presence import PresenceSchedulesDao -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.materialize import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.materialize import ( materialize, regenerate_for_child, regenerate_for_definition, ) -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.quest_definitions import ( +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import ( create_quest_definition, edit_quest_definition, ) -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, ScheduleRule, occurs_on, @@ -505,7 +505,7 @@ def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None: def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -628,7 +628,7 @@ def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None: def test_materialize_honours_overrides(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -758,7 +758,7 @@ def test_materialize_clamps_past_start_to_today(tmp_path) -> None: def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.quest_definitions import ( + from custom_components.nestquest.core.quest_definitions import ( set_quest_definition_active, ) @@ -906,7 +906,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history( tmp_path, ) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -1091,7 +1091,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( def test_regenerate_for_child_is_child_scoped(tmp_path) -> None: """Regenerating one child must never touch another child's rows.""" async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -1493,10 +1493,10 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp) async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) - from custom_components.nestquest.presence import PresenceSchedule + from custom_components.nestquest.core.presence import PresenceSchedule children = ChildrenDao(database) schedules = PresenceSchedulesDao(database) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index b49d14a..0bfca68 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -9,16 +9,16 @@ from unittest.mock import AsyncMock import pytest -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL -from custom_components.nestquest.migrations import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL +from custom_components.nestquest.core.migrations import ( MIGRATIONS, VERSION_TABLE, VERSION_TABLE_DDL, apply_migrations, read_schema_version, ) -from custom_components.nestquest.schema import ( +from custom_components.nestquest.core.schema import ( SCHEMA_V1_STATEMENTS, 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) finally: _run(database.close()) - - -# --------------------------------------------------------------------------- -# Startup wiring: setup runs the migration runner, not raw DDL -# --------------------------------------------------------------------------- - - -async def _setup_entry(hass, entry, registry) -> object: - from tests.test_lifecycle import _wire # reuse the established wiring - - entry = _wire(entry, registry) - from custom_components.nestquest import async_setup_entry - - await async_setup_entry(hass, entry) - return entry - - -async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None: - import sqlite3 as sqlite3_mod - - from custom_components.nestquest.migrations import read_schema_version - from custom_components.nestquest.store import async_get_db_path - - entry = await _setup_entry(hass, make_entry(), hass.registry) - database = entry.runtime_data.database - assert await read_schema_version(database) == len(MIGRATIONS) - # The v1 tables exist because the runner applied migration 1. - names = { - row[0] - for row in await database.fetch_all( - "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name NOT LIKE 'sqlite_%'" - ) - } - assert "children" in names and "completion_events" in names - # The version table exists with its singleton row stamped. - columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})") - assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)] - db_path = await async_get_db_path(hass) - conn = sqlite3_mod.connect(db_path) - try: - conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)") - violated = False - except sqlite3_mod.IntegrityError: - violated = True - finally: - conn.close() - assert violated, "singleton-row CHECK must be live in the setup-created DB" - await database.close() - - -async def test_options_reload_reopens_and_rechecks_version( - hass, make_entry -) -> None: - """A listener-triggered reload re-runs setup against the same file. - - Unload then setup is the reload lifecycle: the old connection is - closed, apply_migrations runs again on the persisted file, keeps - version and rows intact, and the reopened connection is live. - """ - from custom_components.nestquest import async_unload_entry - from custom_components.nestquest.migrations import read_schema_version - - entry = await _setup_entry(hass, make_entry(), hass.registry) - database_a = entry.runtime_data.database - await database_a.execute( - "INSERT INTO children (display_name, created_at) VALUES (?, ?)", - ("Ada", "2026-09-13T00:00:00+00:00"), - ) - assert await async_unload_entry(hass, entry) is True - assert not database_a.connected - - entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry) - database_b = entry_reloaded.runtime_data.database - assert database_b is not database_a - try: - assert await read_schema_version(database_b) == len(MIGRATIONS) - row = await database_b.fetch_one("SELECT COUNT(*) FROM children") - assert row == (1,) - finally: - await database_b.close() - - -async def test_setup_entry_failure_closes_database( - hass, make_entry, monkeypatch -) -> None: - """A failing migration closes the opened connection before re-raising. - - A migration failure surfaces as ConfigEntryNotReady (HA retries - setup) with the original error preserved as __cause__. Spies on - the real wrapper instance: records whether close() ran, rather - than inferring from hass.data (a failed setup never stores a - runtime record). - """ - import pytest as pytest_module - from homeassistant.exceptions import ConfigEntryNotReady - - import custom_components.nestquest as nestquest_module - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import ( - MIGRATIONS as _MIGRATIONS, - ) - - async def _failing(database, migrations=_MIGRATIONS): - raise RuntimeError("boom") - - monkeypatch.setattr( - nestquest_module, "apply_migrations", _failing, raising=True - ) - - close_calls: list[bool] = [] - original_close = NestQuestDatabase.close - - async def _spy_close(self): - close_calls.append(True) - return await original_close(self) - - monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) - - entry = make_entry() - with pytest_module.raises(ConfigEntryNotReady) as excinfo: - await _setup_entry(hass, entry, hass.registry) - assert isinstance(excinfo.value.__cause__, RuntimeError) - assert "boom" in str(excinfo.value.__cause__) - - assert close_calls == [True], ( - "failed setup must close the connection it opened" - ) - record = hass.data["nestquest"].get(entry.entry_id) - assert record is None, "failed setup must not store runtime data" \ No newline at end of file diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index b94b319..3504127 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -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 options_flow from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_AFTERNOON_REMINDER_TIME, CONF_API_BASE_URL, - CONF_CELEBRATION_ENABLED, - CONF_DAY_ROLLOVER_TIME, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_END_OF_DAY_REPORT_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_ENABLED, - CONF_MORNING_SUMMARY_TIME, - CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, - DEFAULT_AFTERNOON_REMINDER_TIME, - DEFAULT_AUTOMATION_ENABLED, - DEFAULT_END_OF_DAY_REPORT_TIME, - DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, - DEFAULT_DAY_ROLLOVER_TIME, - DEFAULT_HORIZON_DAYS, - DEFAULT_PANEL_IDLE_TIMEOUT, DOMAIN, ) @@ -49,27 +32,17 @@ STRINGS_PATH = PKG_DIR / "strings.json" TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" VALID_INPUT = { - CONF_HORIZON_DAYS: 7, - CONF_DAY_ROLLOVER_TIME: "03:30", CONF_PANEL_IDLE_TIMEOUT: 60, } #: What a schema-validated submit produces from a bare VALID_INPUT on a -#: fresh entry: the Feature 10 refresh interval, the Feature 11 -#: notification section, and the Feature 18 API connection fill from +#: fresh entry: the Feature 10 refresh interval, the Feature 18 +#: staleness threshold, and the Feature 18 API connection fill from #: their defaults. FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS, - CONF_NOTIFY_TARGET: "", - CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME, - CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_API_BASE_URL: DEFAULT_API_BASE_URL, CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, } @@ -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) -def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow: - """Create an options flow instance bound to a stubbed config entry. - - ``users`` populates a stubbed hass.auth for the admin picker; - ``database`` wires the runtime record so the flow can read and - write the allowlist like real HA's options flow does. - """ - from unittest.mock import AsyncMock - +def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry.""" flow = options_flow.NestQuestOptionsFlow(config_entry=entry) flow.hass = SimpleNamespace() - flow.hass.auth = SimpleNamespace( - async_get_users=AsyncMock(return_value=users or []) - ) - if database is not None: - from custom_components.nestquest.const import DOMAIN - - flow.hass.data = { - DOMAIN: {entry.entry_id: SimpleNamespace(database=database)} - } return flow -def _user(user_id: str, name: str): - return SimpleNamespace(id=user_id, name=name) - - -def _open_allowlist_db(tmp_path, name): - """A real migrated temp database for the flow to read and write.""" - from unittest.mock import AsyncMock, MagicMock - - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations - - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - database = NestQuestDatabase(hass.async_add_executor_job) - _run(database.open(tmp_path / name)) - _run(apply_migrations(database)) - return database - - def _schema_values(schema) -> dict: """Extract the per-field default values from a real voluptuous schema.""" return { @@ -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: """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) assert isinstance(flow, _options_flow_base()) 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: @@ -194,86 +132,66 @@ def test_options_flow_prefills_from_defaults() -> None: flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS - assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT + assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL + assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS def test_options_flow_prefills_from_entry_options() -> None: """Stored entry options win over entry data and defaults.""" entry = _make_entry( options={ - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, }, data={ - CONF_HORIZON_DAYS: 99, - CONF_DAY_ROLLOVER_TIME: "23:59", CONF_PANEL_IDLE_TIMEOUT: 999, + CONF_UPDATE_INTERVAL: 999, + CONF_SNAPSHOT_STALENESS: 999, }, ) result = _run(_make_flow(entry).async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == 21 - assert values[CONF_DAY_ROLLOVER_TIME] == "05:45" assert values[CONF_PANEL_IDLE_TIMEOUT] == 120 + assert values[CONF_UPDATE_INTERVAL] == 90 + assert values[CONF_SNAPSHOT_STALENESS] == 450 def test_options_flow_falls_back_to_entry_data() -> None: """Pre-upgrade entries without options fall back to entry.data.""" entry = _make_entry( data={ - CONF_HORIZON_DAYS: 10, - CONF_DAY_ROLLOVER_TIME: "01:15", CONF_PANEL_IDLE_TIMEOUT: 90, + CONF_UPDATE_INTERVAL: 60, + CONF_SNAPSHOT_STALENESS: 240, } ) result = _run(_make_flow(entry).async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == 10 - assert values[CONF_DAY_ROLLOVER_TIME] == "01:15" assert values[CONF_PANEL_IDLE_TIMEOUT] == 90 + assert values[CONF_UPDATE_INTERVAL] == 60 + assert values[CONF_SNAPSHOT_STALENESS] == 240 def test_options_flow_schema_applies_stored_defaults() -> None: - """A real voluptuous validate({}) fills every field from the stored defaults. - - 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. - """ + """A real voluptuous validate({}) fills every field from the stored defaults.""" entry = _make_entry( options={ - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, CONF_SNAPSHOT_STALENESS: 450, - CONF_NOTIFY_TARGET: "notify.mobile_app_test", - CONF_MORNING_SUMMARY_TIME: "07:00", - CONF_AFTERNOON_REMINDER_TIME: "14:00", - CONF_END_OF_DAY_REPORT_TIME: "21:30", + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", } ) result = _run(_make_flow(entry).async_step_init(None)) assert result["data_schema"]({}) == { - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, CONF_SNAPSHOT_STALENESS: 450, - CONF_NOTIFY_TARGET: "notify.mobile_app_test", - CONF_MORNING_SUMMARY_TIME: "07:00", - CONF_AFTERNOON_REMINDER_TIME: "14:00", - CONF_END_OF_DAY_REPORT_TIME: "21:30", - CONF_MORNING_SUMMARY_ENABLED: True, - CONF_AFTERNOON_REMINDER_ENABLED: True, - CONF_END_OF_DAY_REPORT_ENABLED: True, - CONF_CELEBRATION_ENABLED: True, - CONF_API_BASE_URL: DEFAULT_API_BASE_URL, - CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, - CONF_ADMIN_USER_IDS: [], + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", } @@ -283,55 +201,14 @@ def test_options_flow_schema_coerces_field_types() -> None: result = _run(flow.async_step_init(None)) schema = result["data_schema"] validators = {marker.schema: validator for marker, validator in schema.schema.items()} - assert validators[CONF_HORIZON_DAYS] is int - assert validators[CONF_DAY_ROLLOVER_TIME] is str assert validators[CONF_PANEL_IDLE_TIMEOUT] is int + assert validators[CONF_UPDATE_INTERVAL] is int + assert validators[CONF_SNAPSHOT_STALENESS] is int # The Feature 18 API connection fields are free-text strings. assert validators[CONF_API_BASE_URL] is str assert validators[CONF_PANEL_TOKEN] is str -@pytest.mark.parametrize("bad_horizon", [0, -5]) -def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None: - """Zero or negative horizon values yield a field-level error, no exception.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"} - - -def test_options_flow_rejects_malformed_time_not_a_time() -> None: - """Non-HH:MM strings are rejected with invalid_time.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - -def test_options_flow_rejects_malformed_time_out_of_range() -> None: - """Out-of-range HH:MM values are rejected with invalid_time.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - -def test_options_flow_rejects_pasted_unicode_superscript_time() -> None: - """Pasted Unicode digits like '²²:²²' yield a field-level error, no exception.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - @pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10]) def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: """Non-numeric or too-low idle timeouts yield a field-level error.""" @@ -343,10 +220,21 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} +@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29]) +def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None: + """Non-numeric, boolean, and below-floor update intervals reject.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"} + + def test_options_flow_creates_entry_with_validated_keys() -> None: """A valid submit creates an entry with exactly the validated keys - (the legacy settings plus every later section's keys, filled from - defaults where the raw submit omitted them).""" + (the client-facing settings, filled from defaults where the raw + submit omitted them).""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -361,7 +249,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( entry = _make_entry() entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) - # Simulate setup registering the merged lifecycle update listener. + # Simulate setup registering the lifecycle update listener. from custom_components.nestquest import async_setup_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: """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()) result = _run(flow.async_step_init(junk)) assert result["type"] == "form" assert result["errors"] == { - CONF_HORIZON_DAYS: "invalid", - CONF_DAY_ROLLOVER_TIME: "invalid_time", CONF_PANEL_IDLE_TIMEOUT: "invalid", + CONF_UPDATE_INTERVAL: "invalid", } @@ -417,10 +304,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di assert init["title"] assert init["description"] data = init["data"] - for key in (CONF_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}'" errors = options["errors"] - for key in ("invalid", "invalid_time"): + for key in ("invalid",): 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}" ) -# --------------------------------------------------------------------------- -# 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) @@ -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_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. result = _run(flow.async_step_init(dict(VALID_INPUT))) 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: """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()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -998,7 +542,7 @@ def test_options_flow_staleness_stored_values_round_trip() -> None: schema_defaults = form["data_schema"]({}) assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200 - # A raw submit carrying only the 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). result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" diff --git a/tests/test_presence.py b/tests/test_presence.py index dd33184..fd17270 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.presence import ( +from custom_components.nestquest.core.presence import ( MAX_CYCLE_LENGTH_WEEKS, PresenceEngine, PresenceOverride, diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index d1d01da..6a534c8 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -7,20 +7,20 @@ import re 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.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.quest_definitions import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import ( CreatedQuestDefinition, assign_child, create_quest_definition, @@ -31,7 +31,7 @@ from custom_components.nestquest.quest_definitions import ( set_quest_definition_active, unassign_child, ) -from custom_components.nestquest.recurrence import RuleType, ScheduleRule +from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule def _run(coro): @@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster( ) bo = await children.create("Bo", NOW) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_assignees started = asyncio.Event() @@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None: start_date="2026-09-14", ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_windows started = asyncio.Event() @@ -1732,7 +1732,7 @@ def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign( database, "Brush teeth", _daily_rule(), [child.id], ["morning"] ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_assignees started = asyncio.Event() @@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit( start_date="2026-09-14", ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_windows started = asyncio.Event() diff --git a/tests/test_recurrence_bounds.py b/tests/test_recurrence_bounds.py index 2098d24..c1e81d6 100644 --- a/tests/test_recurrence_bounds.py +++ b/tests/test_recurrence_bounds.py @@ -12,7 +12,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_consolidated.py b/tests/test_recurrence_consolidated.py index 441cc7d..9dfaf8f 100644 --- a/tests/test_recurrence_consolidated.py +++ b/tests/test_recurrence_consolidated.py @@ -15,7 +15,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( ScheduleRule, RuleType, occurs_on, @@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None: module_file = Path( importlib.util.find_spec( - "custom_components.nestquest.recurrence" + "custom_components.nestquest.core.recurrence" ).origin ) tree = ast.parse(module_file.read_text()) diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index 4505d38..e8a8c79 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -219,7 +219,7 @@ def test_occurs_on_rejects_unknown_shapes_loudly() -> None: than a silent wrong answer. Forged via a private enum extension because ScheduleRule validation rejects unknown names.""" import enum as enum_module - from custom_components.nestquest import recurrence as mod + from custom_components.nestquest.core import recurrence as mod ForgedType = enum_module.Enum( "ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"} diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 095ac03..b0cc2cf 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None: "from homeassistant.core import HomeAssistant", "import sqlite3", "from .db import NestQuestDatabase", - "from custom_components.nestquest.db import NestQuestDatabase", + "from custom_components.nestquest.core.db import NestQuestDatabase", "from custom_components.nestquest import db", - "import custom_components.nestquest.dao_rules", + "import custom_components.nestquest.core.dao_rules", "from . import db", "from . import schema", - "from custom_components.nestquest.migrations import apply_migrations", + "from custom_components.nestquest.core.migrations import apply_migrations", ] for probe in probes: offenders = _scan_forbidden_imports(probe) diff --git a/tests/test_recurrence_monthly_day.py b/tests/test_recurrence_monthly_day.py index 8830523..76a3e61 100644 --- a/tests/test_recurrence_monthly_day.py +++ b/tests/test_recurrence_monthly_day.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_monthly_weekday.py b/tests/test_recurrence_monthly_weekday.py index 2e040e9..fd00641 100644 --- a/tests/test_recurrence_monthly_weekday.py +++ b/tests/test_recurrence_monthly_weekday.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_property.py b/tests/test_recurrence_property.py index eb2ef04..790f513 100644 --- a/tests/test_recurrence_property.py +++ b/tests/test_recurrence_property.py @@ -18,7 +18,7 @@ import random import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_weekly.py b/tests/test_recurrence_weekly.py index 10bd181..3c15411 100644 --- a/tests/test_recurrence_weekly.py +++ b/tests/test_recurrence_weekly.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_yearly.py b/tests/test_recurrence_yearly.py index d544f39..e8859be 100644 --- a/tests/test_recurrence_yearly.py +++ b/tests/test_recurrence_yearly.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_schema.py b/tests/test_schema.py index 0067134..4a526de 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,8 +6,8 @@ import sqlite3 import pytest -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import ( SCHEMA_V1_ADMIN_USERS_DDL, SCHEMA_V1_CHILDREN_DDL, SCHEMA_V1_COMPLETION_EVENTS_DDL, diff --git a/tests/test_sensors.py b/tests/test_sensors.py index fa29adb..2b33d9e 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -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 import datetime 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.children import create_child -from custom_components.nestquest.completion import complete_instance from custom_components.nestquest.const import DOMAIN -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import create_quest_definition -from custom_components.nestquest.recurrence import ScheduleRule from custom_components.nestquest.sensor import ( MAX_STATE_LENGTH, _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). -SENSOR_KINDS = ( - "quests_due_today", - "quests_completed_today", - "quests_remaining_today", - "completion_pct_today", -) +def _today_iso() -> str: + return datetime.date.today().isoformat() -def _today() -> datetime.date: - return datetime.date.today() +def _instance(instance_id: int, child_id: int, **overrides) -> dict: + """One panel instance in the API route's payload shape.""" + payload = { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": "open", + "overdue": False, + "completed_at": None, + "on_time": None, + } + payload.update(overrides) + return payload -async def _seed_children(database): - """Three active children: Ada and Bo share one daily quest; Cory has none.""" - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - cory = await create_child(database, "Cory") - today = _today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [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 +def _child(child_id: int, name: str, instances=(), **overrides) -> dict: + """One child in the API route's payload shape, counts precomputed. - -async def _setup_seeded_entry(hass, make_entry): - """Set up the integration, seed three children, refresh: sensors exist. - - Children are seeded AFTER setup (the entry's database only exists - once setup opens it), so the sensors appear through the refresh - listener — the same path production takes when a child is added. + The API precomputes the per-child rollups from the full day; the + builder here derives them from the instance list so a payload can + never drift from its own counts. """ - 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 coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, bo, cory = await _seed_children(database) - await coordinator.async_refresh() - return entry, coordinator, (ada, bo, cory) + assert coordinator.api_client is client + return entry, coordinator, client 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}"] -async def _complete_first_instance(database, coordinator, child) -> None: - """Complete the child's first open instance through the business layer.""" - snapshot_child = _snapshot_child(coordinator, child.id) - instance_id = snapshot_child.instances[0].instance_id - await complete_instance( - database, - instance_id, - actor_source="panel", - actor_child_id=child.id, - ) - - async def test_every_active_child_gets_five_sensors_with_documented_identity( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), _completed_payload() ) - database = entry.runtime_data.database assert hass.config_entries.forwarded_platforms == [ "sensor", "binary_sensor", ] - await _complete_first_instance(database, coordinator, ada) await coordinator.async_refresh() - # Two quest children plus the zero-quest child: three children x - # (five sensors + two binary sensors) + three household sensors, - # no duplicates. + # Three children x (five sensors + two binary sensors) + three + # household sensors, no duplicates. assert len(hass.entities) == 24 expected = { - ada.id: ("Ada", 1, 1, 0, 100), - bo.id: ("Bo", 1, 0, 1, 0), - cory.id: ("Cory", 0, 0, 0, 100), + ADA: ("Ada", 1, 1, 0, 100), + BO: ("Bo", 1, 0, 1, 0), + CORY: ("Cory", 0, 0, 0, 100), } for child_id, (name, due, completed, remaining, pct) in expected.items(): due_entity = _sensor(hass, child_id, "quests_due_today") completed_entity = _sensor(hass, child_id, "quests_completed_today") remaining_entity = _sensor(hass, child_id, "quests_remaining_today") pct_entity = _sensor(hass, child_id, "completion_pct_today") - next_entity = _sensor(hass, child_id, "next_quest") assert due_entity.name == f"NestQuest {name} quests due today" assert completed_entity.name == ( f"NestQuest {name} quests completed today" @@ -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: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - due_entity = _sensor(hass, cory.id, "quests_due_today") - pct_entity = _sensor(hass, cory.id, "completion_pct_today") + due_entity = _sensor(hass, CORY, "quests_due_today") + pct_entity = _sensor(hass, CORY, "completion_pct_today") assert due_entity.native_value == 0 assert pct_entity.native_value == 100, ( "zero quests is a perfect day, not a division error" @@ -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( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_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 assert set(attributes) == { "instances", @@ -184,7 +222,7 @@ async def test_due_today_attributes_carry_the_panel_payload( "child_name", "present", } - assert attributes["child_id"] == ada.id + assert attributes["child_id"] == ADA assert attributes["child_name"] == "Ada" assert attributes["present"] is True [instance] = attributes["instances"] @@ -201,7 +239,7 @@ async def test_due_today_attributes_carry_the_panel_payload( "completed_at", "on_time", } - assert instance["child_id"] == ada.id + assert instance["child_id"] == ADA assert instance["title"] == "Brush teeth" assert instance["window"] == "morning" 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" ) - 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"] assert bo_instance["state"] == "open" 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( - hass, make_entry, monkeypatch + hass, make_entry ) -> None: """D-009: the panel payload omits missed instances. Since Feature 18 the snapshot comes from the API panel route, which omits missed rows entirely (the admin surface for missed - quests is the PWA, reading the API directly). The entity payload - therefore cannot carry missed rows any more: ``admin_instances`` - equals ``instances`` — open and completed rows only. A missed - instance is still derived locally (``due_date`` before the - snapshot's "today"); the pinned-FUTURE clock at the - ``derive_state`` boundary makes today's open instances read - missed through the same derivation rule, and this test pins the - change: neither payload carries them, and the count rollups - (precomputed server-side from the full day) are unaffected here - because the API counts arrive per the route's own build. + quests is the PWA, reading the API directly). The integration + renders exactly what the route sent: a snapshot can never carry a + ``missed`` view, ``admin_instances`` equals ``instances``, and the + precomputed counts arrive untouched — pinned here with an open + instance whose derived state reads missed (a past ``due_time`` + with the counts already rolled up the way the route builds them). """ - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + payload = _payload( + [ + _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) - 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") + bo_due = _sensor(hass, BO, "quests_due_today") attributes = bo_due.extra_state_attributes - assert attributes["instances"] == [], ( - "missed instances are omitted from the panel payload" - ) - assert attributes["admin_instances"] == [], ( + assert attributes["instances"], "an open row renders in the panel payload" + assert attributes["admin_instances"] == attributes["instances"], ( "the API payload omits missed rows, so the admin payload " "carries none either (the PWA is the admin surface now)" ) - # Counts still roll the missed instance up as not-completed: the - # counts arrive precomputed from the route's own build, which rolls - # the FULL day (the missed row included) into the numbers. - assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0 - assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1 + # The counts arrive precomputed from the route's own build, which + # rolls the FULL day (any missed row included) into the numbers. + assert _sensor(hass, BO, "quests_completed_today").native_value == 0 + assert _sensor(hass, BO, "quests_remaining_today").native_value == 1 -@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( hass, make_entry, kind ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - for child in (ada, bo, cory): - entity = _sensor(hass, child.id, kind) + for child_id in (ADA, BO, CORY): + entity = _sensor(hass, child_id, kind) state = entity.native_value 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( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), _completed_payload() ) - database = entry.runtime_data.database - completed_entity = _sensor(hass, ada.id, "quests_completed_today") - remaining_entity = _sensor(hass, ada.id, "quests_remaining_today") - pct_entity = _sensor(hass, ada.id, "completion_pct_today") - due_entity = _sensor(hass, ada.id, "quests_due_today") + completed_entity = _sensor(hass, ADA, "quests_completed_today") + remaining_entity = _sensor(hass, ADA, "quests_remaining_today") + pct_entity = _sensor(hass, ADA, "completion_pct_today") + due_entity = _sensor(hass, ADA, "quests_due_today") assert completed_entity.native_value == 0 assert remaining_entity.native_value == 1 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 - await _complete_first_instance(database, coordinator, ada) await coordinator.async_refresh() 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( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) 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( hass, make_entry ) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _payload([]) + ) # No children means no per-child entities; the three household # rollups still exist (an empty household is a valid 0). 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: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True + entry, _coordinator, _client = await _setup_entry(hass, make_entry) assert await async_unload_entry(hass, entry) is True assert hass.config_entries.unloaded_platforms == [ "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( hass, make_entry ) -> None: - """A manage_child rename shows on the next refresh: the name and - device label re-derive from the snapshot while the unique_id (and - therefore history) is untouched.""" - from custom_components.nestquest.children import edit_child - - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + """A rename in the API's snapshot shows on the next refresh: the + name and device label re-derive from the snapshot while the + unique_id (and therefore history) is untouched.""" + renamed = _payload( + [ + _child(ADA, "Mirren", [_instance(7, ADA)]), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] ) - database = entry.runtime_data.database - due_sensor = _sensor(hass, ada.id, "quests_due_today") + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), renamed + ) + due_sensor = _sensor(hass, ADA, "quests_due_today") original_unique_id = due_sensor.unique_id assert due_sensor.name.startswith("NestQuest Ada ") - await edit_child(database, ada.id, display_name="Mirren") await coordinator.async_refresh() assert due_sensor.name.startswith("NestQuest Mirren ") @@ -391,12 +430,11 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime( hass, make_entry ) -> None: """When HA reports platforms still loaded, the unload must NOT - close the database the live entities read: it returns False and - keeps the runtime record for a retry.""" - entry, _coordinator, _children = await _setup_seeded_entry( - hass, make_entry + tear down the coordinator the live entities read: it returns False + and keeps the runtime record for a retry.""" + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - database = entry.runtime_data.database async def _refuse_unload(entry_arg, platforms): 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 assert await async_unload_entry(hass, entry) is False 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( hass, make_entry ) -> None: """The next-quest sensor carries the earliest open quest's title in state with the full shape in attributes.""" - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_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.native_value == "Brush teeth" attributes = next_entity.extra_state_attributes assert attributes["title"] == "Brush teeth" assert attributes["window"] == "morning" assert attributes["instance_id"] == _snapshot_child( - coordinator, ada.id + coordinator, ADA ).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( @@ -431,14 +470,11 @@ async def test_next_quest_sensor_reports_none_sentinel_when_day_clear( ) -> None: """A child with every quest done (and a zero-quest child) reports the documented sentinel, never an empty string.""" - entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _completed_payload() ) - database = entry.runtime_data.database - await _complete_first_instance(database, coordinator, ada) - await coordinator.async_refresh() - assert _sensor(hass, ada.id, "next_quest").native_value == "none" - assert _sensor(hass, cory.id, "next_quest").native_value == "none" + assert _sensor(hass, ADA, "next_quest").native_value == "none" + assert _sensor(hass, CORY, "next_quest").native_value == "none" 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: """A quest title longer than the state limit is truncated; the attribute keeps the full title.""" - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + long_title = "A very long quest title. " * 30 + payload = _payload( + [_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])] ) - database = entry.runtime_data.database - from custom_components.nestquest.quest_definitions import ( - edit_quest_definition, - ) - - snapshot_child = _snapshot_child(coordinator, ada.id) - await edit_quest_definition( - database, - snapshot_child.instances[0].definition_id, - title="A very long quest title. " * 30, - ) - await coordinator.async_refresh() - next_entity = _sensor(hass, ada.id, "next_quest") - full_title = _snapshot_child(coordinator, ada.id).instances[0].title + entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload) + next_entity = _sensor(hass, ADA, "next_quest") + full_title = _snapshot_child( + entry.runtime_data.coordinator, ADA + ).instances[0].title assert len(full_title) > 255 state = next_entity.native_value assert len(state) <= 255 @@ -473,19 +501,21 @@ async def test_household_rollups_sum_the_active_children( hass, make_entry ) -> None: """Household due/completed equal the sum of the per-child sensors; - the cycle-day sensor carries the coordinator's answer.""" - import datetime as _dt - - from custom_components.nestquest.dao_presence import ( - PresenceSchedulesDao, + the cycle-day sensor carries the coordinator's answer (precomputed + by the API, which resolves the schedule engine server-side).""" + payload = _payload( + [ + _child( + ADA, + "Ada", + [_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)], + ), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] ) - - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry - ) - database = entry.runtime_data.database - await _complete_first_instance(database, coordinator, ada) - await coordinator.async_refresh() + payload["cycle_day"] = 4 + entry, coordinator, _client = await _setup_entry(hass, make_entry, payload) household_due = hass.entities["nestquest_household_quests_due_today"] 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"] per_child_due = [ - _sensor(hass, child.id, "quests_due_today").native_value - for child in (ada, bo, cory) + _sensor(hass, child_id, "quests_due_today").native_value + for child_id in (ADA, BO, CORY) ] per_child_completed = [ - _sensor(hass, child.id, "quests_completed_today").native_value - for child in (ada, bo, cory) + _sensor(hass, child_id, "quests_completed_today").native_value + for child_id in (ADA, BO, CORY) ] assert household_due.native_value == sum(per_child_due) 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" ) 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.extra_state_attributes["today"] == ( - today.isoformat() - ) + assert cycle_day_entity.extra_state_attributes["today"] == _today_iso() diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py deleted file mode 100644 index 323c7c3..0000000 --- a/tests/test_startup_db.py +++ /dev/null @@ -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}" - ) \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py deleted file mode 100644 index ae773c7..0000000 --- a/tests/test_store.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/tests/test_sweep.py b/tests/test_sweep.py deleted file mode 100644 index c76ac81..0000000 --- a/tests/test_sweep.py +++ /dev/null @@ -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 diff --git a/tests/test_timezones.py b/tests/test_timezones.py deleted file mode 100644 index 0d1dce5..0000000 --- a/tests/test_timezones.py +++ /dev/null @@ -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 -- 2.55.0