Compare commits

..
Author SHA1 Message Date
Joshua King caea163e17 test: race hook pauses after validation passes, before INSERT; assert create must win that ordering
The pause point is now the actual TOCTOU window (validation passed,
INSERT pending): the create-first ordering asserts deleted is False -
the serialized transaction must keep the queued delete from removing
the rule between the create's check and its insert. Tautological or
True assertion removed.
2026-09-14 07:46:34 -04:00
Joshua King 3a7ab12ab2 fix: atomic assignment validation, sentinel-based clearing, strict date validation, race test both orderings
- create/set_assignee validate child+rule inside the same serialized
  transaction as the INSERT/UPDATE (time-of-check/time-of-use race
  closed; inactive child or deleted rule at assignment time raises
  ValueError, never a raw FK error)
- update() methods take an _UNSET sentinel: omitted = leave alone,
  explicit None = write SQL NULL (reopen ended rules, clear metadata)
- rule create/update validate strict YYYY-MM-DD date shape (lexical
  CHECK cannot prove calendar validity)
- delete/create race test now forces both orderings via a validation
  hook; SQL-leak guard no longer exempts dao_children.py
2026-09-14 07:44:28 -04:00
Joshua King 46a0a3445c feat: add typed DAO layer for schedule_rules and task_definitions (#19) 2026-09-14 07:34:42 -04:00
orchestrator-agent 43b20f2b12 DAO layer for children and admin_users (#18) 2026-09-14 07:26:34 -04:00
orchestrator-agent 2c8c90a24e Implement the versioned migration runner (#17) 2026-09-14 07:10:23 -04:00
orchestrator-agent e98642e236 Schema v1: task_instances and completion_events tables (#16) 2026-09-14 06:53:14 -04:00
dev_agent 01905ec9ee feat: add schema v1 ddl for presence schedules and overrides (#15) 2026-09-13 21:59:59 -04:00
dev_agent 30748f786a feat: add schema v1 ddl for schedule_rules and task_definitions (#14) 2026-09-13 21:50:18 -04:00
dev_agent 3dae2eadd7 feat: add schema v1 ddl for children and admin_users (#13) 2026-09-13 21:39:18 -04:00
dev_agent eaf2e45e9a feat: add async sqlite connection wrapper with executor-backed queries (#12) 2026-09-13 21:35:36 -04:00
42 changed files with 348 additions and 10298 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Home Assistant custom integration for managing chores, custody schedules, and ho
## Overview
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.2.0 ships the integration foundation (a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow for horizon days / day rollover time / panel idle timeout, and HACS metadata) plus its persistence layer: an integration-owned SQLite database (`nestquest.db`) with WAL mode and foreign keys, a versioned migration runner stamping schema version 1 across eight tables (children, admin_users, schedule_rules, task_definitions, presence_schedules, presence_overrides, task_instances, completion_events), typed DAO layers so no other feature writes raw SQL, an append-only completion-event log, and startup handling that creates a missing database, opens a valid one, and refuses — without touching a single byte — to start on a corrupt one. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration.
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. It provides sensors, calendar events, and service calls designed for busy households and modern co-parenting setups.
## Installation
-129
View File
@@ -1,129 +0,0 @@
# NestQuest Release Notes
## Version 0.3.0 — 2026-09-14
### Scope
Third release of NestQuest, shipping Feature 04 — the recurrence rule engine:
- `ScheduleRule`: an immutable, validated rule model (frozen dataclass) covering six recurrence shapes — daily, weekly (weekday sets), monthly by day-of-month, monthly by nth-weekday, yearly, and custom day-sets — with interval multipliers (every N days/weeks/months) and optional start/end dates.
- `occurs_on(rule, date)`: pure calendar evaluation answering "does this rule fire on this date?"
- `occurrences_between(rule, start, end)`: ordered, duplicate-free date lists, inclusive bounds, clamped to the rule's window.
- Documented month-end policy: a rule for a day absent from a month (the 31st in April, the 30th/29th in February) fires on that month's LAST day — one firing per eligible month, interval anchored on nominal months elapsed.
- Documented leap-day policy: a YEARLY February 29th rule fires February 28th in non-leap years (clamp, never skip).
- Week offsets anchored at `start_date` by whole weeks elapsed — never ISO week numbers or parity (53-week years cannot silently invert schedules).
- The engine is pure: no Home Assistant imports, no database access, no timezone logic.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Known Limitations
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release (data layer + recurrence engine only).
- No business logic yet: nothing materializes task instances (Feature 07 combines this engine with presence schedules) — storage and scheduling math only.
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
### Rollback
Downgrading from 0.3.0 to 0.2.0 removes the recurrence engine's code but leaves the `nestquest.db` file in place (untouched by both releases; no code path deletes an existing database). The file's schema version stamp is unaffected — this release ships no schema migration.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.2.0) or Remove entirely.
2. Restart Home Assistant.
3. If removing: delete the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
**Manual installs:**
1. Replace or delete the `custom_components/nestquest/` directory in your Home Assistant configuration directory.
2. Restart Home Assistant.
**Data:** the `nestquest.db` file (plus any `-wal`/`-shm` sidecars) is intentionally left untouched by both upgrade and rollback — back it up before upgrading.
**Repository operators:** instead of uninstalling, git-revert the release merge on `main`.
## Version 0.2.0 — 2026-09-14
### Scope
Second release of NestQuest, shipping Feature 02 — the persistence layer the integration owns outright:
- Integration-owned SQLite database (`nestquest.db`) in the Home Assistant config directory: WAL journal mode, foreign-key enforcement, executor-backed access so the event loop never blocks.
- Schema v1 for all eight tables: `children`, `admin_users`, `schedule_rules`, `task_definitions`, `presence_schedules`, `presence_overrides`, `task_instances`, `completion_events` — validated by declarative CHECK constraints (no triggers).
- Versioned migration runner: on first start the database is created at schema version 1; later starts are a verified no-op; a failed migration rolls back to its prior version. A database stamped with a future version refuses to start.
- Typed DAO layers for every table so no other feature writes raw SQL. Assignment validation is atomic; `completion_events` is strictly append-only — the DAO exposes no update or delete method, and a package-wide guard keeps mutation SQL out of every module.
- Startup handling for missing (created fresh), valid (opened), and corrupt (logged, retried, left byte-identical for backup restore) database files via a read-only `integrity_check` preflight that cannot write or create sidecars. An existing zero-byte file is treated as corrupt and left untouched, not silently initialized.
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Known Limitations
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release (data layer only).
- No business logic yet: nothing materializes task instances and nothing can be completed — storage and schema only.
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
### Rollback
Downgrading from 0.2.0 to 0.1.0 removes the data layer's code but leaves the `nestquest.db` file in place (0.1.0 never touches it, and no code path deletes an existing database). The file is compatible with this release's schema version stamp; a later reinstall of 0.2.0 or newer resumes from the stored version without re-migrating.
**HACS installs:**
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.1.0) or Remove entirely.
2. Restart Home Assistant.
3. If removing: delete the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
**Manual installs:**
1. Replace or delete the `custom_components/nestquest/` directory in your Home Assistant configuration directory.
2. Restart Home Assistant.
**Data:** the `nestquest.db` file (plus any `-wal`/`-shm` sidecars) is intentionally left untouched by both upgrade and rollback — back it up before upgrading. Removing the integration via Home Assistant deletes the config entry but not the database file.
**Repository operators:** instead of uninstalling, git-revert the release merge on `main`. The database file is independent of the code and is never deleted by code.
## Version 0.1.0 — 2026-09-13
### Scope
First release of NestQuest, a Home Assistant custom integration for managing chores, custody schedules, and house coordination. This release ships the integration foundation/configuration scaffold only (Feature 01):
- Integration package and manifest, plus HACS metadata (`hacs.json`).
- Shared constants module.
- Single-instance config flow for initial setup.
- Setup/unload lifecycle handling with an options-reload listener.
- Options flow for configuring `horizon_days`, `day_rollover_time`, and `panel_idle_timeout`.
- SQLite storage path resolver — path resolution only; the database itself is not created in this release.
- Test harness (`pytest` via `uv`).
### Requirements
- Minimum Home Assistant version: **2024.6.0**.
### Known Limitations
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release.
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
- The integration does not yet create its database file; storage is limited to resolved configuration paths.
### Rollback
v0.1.0 is the first release — there is no previous NestQuest version to downgrade to, so rollback means complete removal. This release performs no data or schema migrations.
**HACS installs:**
1. Remove the NestQuest integration via HACS (HACS > Integrations > NestQuest > Remove).
2. Restart Home Assistant.
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
**Manual installs:**
1. Delete the `custom_components/nestquest/` directory from your Home Assistant configuration directory.
2. Restart Home Assistant.
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
+4 -208
View File
@@ -1,17 +1,13 @@
"""The NestQuest integration."""
from __future__ import annotations
import asyncio
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, LOGGER
from .db import NestQuestDatabase
@@ -20,200 +16,6 @@ from .store import async_get_db_path
_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 NestQuestDatabase(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:
@@ -236,15 +38,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
if db is None:
db = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
existing.database = db
entry.runtime_data = existing
return True
@@ -255,8 +50,9 @@ 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))
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
try:
await apply_migrations(database)
remove_update_listener = entry.add_update_listener(_async_update_listener)
except BaseException:
await database.close()
-25
View File
@@ -20,28 +20,3 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Quest windows (D-008): a definition may declare several day windows;
# each produces its own instance per day and groups the panel's Quest
# Log into three columns. The clock ranges are stored here so they stay
# configurable later. Boundaries: a window's end equals the next
# window's start; classification (Feature 07 materialisation) treats a
# due time exactly on the boundary as belonging to the LATER window, so
# 12:00 is afternoon (not morning) and 17:00 is evening (not
# afternoon). Times after 21:00 belong to no window.
WINDOW_MORNING = "morning"
WINDOW_AFTERNOON = "afternoon"
WINDOW_EVENING = "evening"
QUEST_WINDOWS: tuple[str, ...] = (
WINDOW_MORNING,
WINDOW_AFTERNOON,
WINDOW_EVENING,
)
#: Inclusive "HH:MM" bounds per window, in 24-hour local time.
WINDOW_CLOCK_RANGES: dict[str, tuple[str, str]] = {
WINDOW_MORNING: ("00:00", "11:59"),
WINDOW_AFTERNOON: ("12:00", "17:00"),
WINDOW_EVENING: ("17:00", "21:00"),
}
@@ -1,549 +0,0 @@
"""Typed DAO layer for quest_instances and the append-only completion_events.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Append-only policy (Feature 08, D-005): ``completion_events`` is
strictly append-only. This module is where that property is enforced
mechanically — the completion-events DAO below exposes NO update or
delete method at all; completions append, un-completions append a
separate reversal event, and instance state derives from the latest
event. A package-wide guard test (bottom of the test module) keeps
mutation SQL for this table out of every other module.
Upsert semantics: ``QuestInstancesDao.upsert`` is idempotent on
(definition_id, due_date) — re-running materialization for a date
whose instance already exists updates nothing that matters
(child_id/due_time are refreshed only in the degenerate re-generation
case where they changed) and never duplicates a row. The schema's
UNIQUE(definition_id, due_date) is the last line of defense. The
upsert refuses a child that is not an assignee of the definition
(D-008 multi-assignee model).
``delete_future_uncompleted`` implements the Feature 06/07 rule that
reassignment and schedule edits regenerate future instances: it
removes only instances at or after the cutoff that have NO completion
event, inside one transaction, so an instance completed mid-call can
never be deleted. Instances in the past are never touched (Feature 07
guardrail: never delete history), and no method here deletes a single
instance that has a completion event.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _validate_date
from .db import NestQuestDatabase
#: Timestamp policy for completion_events.occurred_at: strict UTC
#: ISO-8601 with explicit '+00:00' offset (module timestamp policy).
#: One accepted shape bounds lexicographic range queries correctly, so
#: the occurred-at scope does not guess at timestamp spellings.
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip the parsed value against the input so
non-padded or re-ordered shapes are rejected. The round-trip
strips the UTC offset (isoformat() omits it when the offset is
zero), so the suffix is appended before comparing.
"""
try:
parsed = datetime.datetime.strptime(
value, _UTC_TIMESTAMP_FORMAT
)
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a UTC ISO-8601 timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
) from None
if parsed.isoformat() + "+00:00" != value:
raise ValueError(
f"{field} must be a strict UTC timestamp "
f"(YYYY-MM-DDTHH:MM:SS+00:00), got {value!r}"
)
def _next_day_start(date_str: str) -> str:
"""Return the UTC timestamp one second past ``date_str``'s last second.
Gives range queries an exact half-open upper bound without
fractional-second guessing.
"""
day = datetime.date.fromisoformat(date_str)
next_day = day + datetime.timedelta(days=1)
return f"{next_day.isoformat()}T00:00:00+00:00"
@dataclass(frozen=True)
class QuestInstanceRecord:
"""One row of ``quest_instances``."""
id: int
definition_id: int
child_id: int
due_date: str
due_time: str | None
generated_at: str
@dataclass(frozen=True)
class CompletionEventRecord:
"""One row of ``completion_events`` (append-only table)."""
id: int
instance_id: int
child_id: int
event_type: str
actor_source: str
actor_user_id: str | None
occurred_at: str
was_on_time: bool | None
_INSTANCE_COLUMNS = (
"id, definition_id, child_id, due_date, due_time, generated_at"
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
#: the backstop, this is the DAO's typed front door.
EVENT_COMPLETED = "completed"
EVENT_UNCOMPLETED = "uncompleted"
def _instance_from_row(row: tuple) -> QuestInstanceRecord:
return QuestInstanceRecord(
id=row[0],
definition_id=row[1],
child_id=row[2],
due_date=row[3],
due_time=row[4],
generated_at=row[5],
)
def _event_from_row(row: tuple) -> CompletionEventRecord:
return CompletionEventRecord(
id=row[0],
instance_id=row[1],
child_id=row[2],
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
occurred_at=row[6],
was_on_time=None if row[7] is None else bool(row[7]),
)
class QuestInstancesDao:
"""Typed async access to the ``quest_instances`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
due_time: str | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for (definition_id, due_date) idempotently.
Running materialization twice for the same definition/date
yields ONE row: the ON CONFLICT path refreshes the
generation-time snapshot columns (child_id, due_time) and
regenerates the stamp, but can never create a duplicate — the
schema UNIQUE(definition_id, due_date) backs this up.
Guardrails enforced here:
- ``due_date`` must be TODAY or later: instances are never
generated in the past (Feature 07). The caller's
materialization horizon is future-dated by definition.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
(Feature 07/08). The upsert refuses with ValueError in that
case — silently no-oping would mask a materialization bug.
The definition must exist (validated atomically under the
connection lock so a concurrent definition deactivation or
child deletion cannot slip between check and insert).
"""
_validate_date(due_date, "due_date")
# Fail fast on obviously-past dates BEFORE queuing on the
# connection lock (a nice early error); the AUTHORITATIVE
# check runs after the lock is acquired, because a caller can
# wait on the lock across midnight — a date that was "today"
# at call time may be "yesterday" by the time the INSERT runs.
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today!r}"
)
async with _connection_lock(self._database):
async with self._database.transaction():
# Re-read "today" under the lock: this is the date the
# insert actually executes on, so the no-past rule
# holds across midnight rollovers.
today = datetime.date.today().isoformat()
if due_date < today:
raise ValueError(
f"instances are never generated in the past: "
f"due_date {due_date!r} is before today "
f"{today!r}"
)
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignee = await self._database.fetch_one(
"SELECT 1 FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
if assignee is None:
raise ValueError(
f"quest definition {definition_id} is not "
f"assigned to child {child_id}; instance must "
"carry an assigned child"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?)",
(definition_id, due_date),
)
if existing is not None:
raise ValueError(
f"instance for (definition {definition_id}, "
f"due_date {due_date!r}) already has a completion "
"event and is immutable; it cannot be regenerated"
)
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, due_date) DO UPDATE SET "
"child_id = excluded.child_id, "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(definition_id, due_date)
assert instance is not None
return instance
async def get(
self, definition_id: int, due_date: str
) -> QuestInstanceRecord | None:
"""Return the instance for (definition_id, due_date), or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE definition_id = ? AND due_date = ?",
(definition_id, due_date),
)
return _instance_from_row(row) if row is not None else None
async def get_by_id(self, instance_id: int) -> QuestInstanceRecord | None:
"""Return the instance with ``instance_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances WHERE id = ?",
(instance_id,),
)
return _instance_from_row(row) if row is not None else None
async def list_by_child_and_date(
self, child_id: int, due_date: str
) -> list[QuestInstanceRecord]:
"""Return the child's instances due exactly on ``due_date``."""
_validate_date(due_date, "due_date")
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date = ? ORDER BY id",
(child_id, due_date),
)
return [_instance_from_row(row) for row in rows]
async def list_by_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[QuestInstanceRecord]:
"""Return the child's instances with due_date in the closed range."""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_INSTANCE_COLUMNS} FROM quest_instances "
"WHERE child_id = ? AND due_date >= ? AND due_date <= ? "
"ORDER BY due_date, id",
(child_id, range_start, range_end),
)
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str
) -> int:
"""Delete the definition's open instances at/after cutoff.
Implements regeneration after a definition edit or reassignment
(Features 06/07): only instances at or after the cutoff with NO
completion event are removed, inside one transaction — an
instance that gains a completion event mid-flight cannot be
deleted. The cutoff cannot be backdated below today: "future"
is measured against TODAY (materialization regenerates the
rolling horizon from now), so a caller cannot use this to
rewrite past open instances either — the day-rollover missed
sweep (Feature 11) handles past-due instances instead. Any
instance with a completion event is never touched, regardless
of its date. Returns the number deleted.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock: the DELETE actually
# executes on this date, so a caller queued across
# midnight cannot delete instances that became past
# while it waited (backdating is clamped to the
# execution date, not the call date).
today = datetime.date.today().isoformat()
effective_cutoff = max(cutoff_date, today)
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(definition_id, effective_cutoff),
)
return result.rowcount
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
APPEND-ONLY BY CONSTRUCTION: this class exposes ``append`` and the
three read methods below, and NOTHING else. There is no update,
no delete, no upsert — completions and un-completions both arrive
through :meth:`append` as new rows, and history is immutable
(Feature 08 done-condition: no code path updates or deletes an
event). The package-wide leak-guard test additionally fails if
mutation SQL for this table ever appears in any module.
"""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def append(
self,
instance_id: int,
child_id: int,
event_type: str,
actor_source: str,
occurred_at: str,
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (with ``actor_user_id`` set) or
'panel' (no user id — the tapped child is ``child_id``); the
actor-pair CHECK enforces this shape at the schema level.
``occurred_at`` must be the module's one strict UTC shape
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
stay exact. ``was_on_time`` is required (True/False) for
completions; for un-completions it may be None or the reversed
completion's flag (Feature 08 still deciding). The instance
and child must exist — validated atomically under the
connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
f"event_type must be '{EVENT_COMPLETED}' or "
f"'{EVENT_UNCOMPLETED}', got {event_type!r}"
)
if actor_source not in ("user", "panel"):
raise ValueError(
f"actor_source must be 'user' or 'panel', got "
f"{actor_source!r}"
)
if actor_source == "user" and not actor_user_id:
raise ValueError(
"actor_source 'user' requires actor_user_id"
)
if actor_source == "panel" and actor_user_id is not None:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
f"{was_on_time!r}"
)
if event_type == EVENT_COMPLETED and was_on_time is None:
raise ValueError(
"a completed event requires was_on_time (True/False)"
)
_validate_utc_timestamp(occurred_at, "occurred_at")
async with _connection_lock(self._database):
async with self._database.transaction():
instance = await self._database.fetch_one(
"SELECT child_id FROM quest_instances WHERE id = ?",
(instance_id,),
)
if instance is None:
raise ValueError(
f"quest instance {instance_id} does not exist"
)
if instance[0] != child_id:
raise ValueError(
f"quest instance {instance_id} belongs to child "
f"{instance[0]}, not {child_id}"
)
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, occurred_at, "
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
)
event = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE id = ?",
(result.lastrowid,),
)
assert event is not None
return _event_from_row(event)
async def list_by_instance(
self, instance_id: int
) -> list[CompletionEventRecord]:
"""Return every event for the instance, in append order."""
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id",
(instance_id,),
)
return [_event_from_row(row) for row in rows]
async def list_by_child_and_date_range(
self,
child_id: int,
range_start: str,
range_end: str,
*,
due_date_scope: bool = True,
) -> list[CompletionEventRecord]:
"""Return the child's events for instances due in the closed range.
Scopes by the INSTANCE's due date (the day the task was owed),
not the event's wall-clock timestamp: history reporting asks
"what happened to the tasks due that day" (Feature 13). Set
``due_date_scope=False`` to filter by ``occurred_at`` date
instead. Ordered by instance, then event id.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
if due_date_scope:
predicate = (
"SELECT id FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND due_date <= ?"
)
parameters: list[object] = [child_id, range_start, range_end]
else:
# Occurred-at scope: occurred_at is validated to the one
# accepted UTC shape at append time, so half-open day
# bounds in that same shape are exact (no spelling drift).
occurred_start = f"{range_start}T00:00:00+00:00"
occurred_end = f"{range_end}T23:59:59+00:00"
query = (
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE child_id = ? AND occurred_at >= ? "
"AND occurred_at < ? ORDER BY instance_id, id"
)
rows = await self._database.fetch_all(
query,
[
child_id,
occurred_start,
_next_day_start(range_end),
],
)
return [_event_from_row(row) for row in rows]
rows = await self._database.fetch_all(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
f"WHERE instance_id IN ({predicate}) "
"ORDER BY instance_id, id",
tuple(parameters),
)
return [_event_from_row(row) for row in rows]
async def get_latest_for_instance(
self, instance_id: int
) -> CompletionEventRecord | None:
"""Return the instance's most recent event, or None if untouched.
Instance current state derives from this event (Feature 08):
'completed' means done, 'uncompleted' as the latest means open
again, None means never completed.
"""
row = await self._database.fetch_one(
f"SELECT {_EVENT_COLUMNS} FROM completion_events "
"WHERE instance_id = ? ORDER BY id DESC LIMIT 1",
(instance_id,),
)
return _event_from_row(row) if row is not None else None
-247
View File
@@ -1,247 +0,0 @@
"""Typed DAO layer for the presence_schedules and presence_overrides tables.
All SQL for these tables lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Semantics per the done-condition and Feature 05:
- A child has AT MOST ONE presence schedule (schema UNIQUE); upserting
by child updates the existing row rather than raising.
- A child with NO schedule row is present every day — the absence of a
row is meaningful state, expressed by :meth:`PresenceSchedulesDao.get_by_child`
returning None, never by an empty pattern row.
- Overrides beat the pattern for their date range (that resolution is
the presence engine's job, Feature 05); the DAO only stores and
lists them. A single-day override stores the same date as both
start and end (schema CHECK requires end >= start).
Date arguments are validated as strict YYYY-MM-DD per the same policy
the rules DAO applies: the schema's lexical CHECKs cannot prove
calendar validity.
"""
from __future__ import annotations
from dataclasses import dataclass
from .dao_children import _connection_lock
from .dao_rules import _UNSET, _validate_date
from .db import NestQuestDatabase
@dataclass(frozen=True)
class PresenceScheduleRecord:
"""One row of ``presence_schedules``."""
id: int
child_id: int
cycle_length_weeks: int
anchor_date: str
pattern: str
@dataclass(frozen=True)
class PresenceOverrideRecord:
"""One row of ``presence_overrides``."""
id: int
child_id: int
start_date: str
end_date: str
is_present: bool
note: str | None
_SCHEDULE_COLUMNS = (
"id, child_id, cycle_length_weeks, anchor_date, pattern"
)
_OVERRIDE_COLUMNS = (
"id, child_id, start_date, end_date, is_present, note"
)
def _schedule_from_row(row: tuple) -> PresenceScheduleRecord:
return PresenceScheduleRecord(
id=row[0],
child_id=row[1],
cycle_length_weeks=row[2],
anchor_date=row[3],
pattern=row[4],
)
def _override_from_row(row: tuple) -> PresenceOverrideRecord:
return PresenceOverrideRecord(
id=row[0],
child_id=row[1],
start_date=row[2],
end_date=row[3],
is_present=bool(row[4]),
note=row[5],
)
class PresenceSchedulesDao:
"""Typed async access to the ``presence_schedules`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def upsert_by_child(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: str,
pattern: str,
) -> PresenceScheduleRecord:
"""Set the child's presence schedule, replacing any existing one.
Upsert semantics are the done-condition: a second schedule for
the same child updates the existing row rather than raising.
Runs under the connection lock so a concurrent upsert/delete
for the same child serializes instead of colliding on the
schema's UNIQUE(child_id). Pattern validity (segment shape,
segment count = cycle_length_weeks) is enforced by the schema
CHECKs; the child must exist (FK) — validated atomically here
so a deleted child cannot pass a stale check.
"""
_validate_date(anchor_date, "anchor_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
await self._database.execute(
"INSERT INTO presence_schedules (child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT (child_id) DO UPDATE SET "
"cycle_length_weeks = excluded.cycle_length_weeks, "
"anchor_date = excluded.anchor_date, "
"pattern = excluded.pattern",
(child_id, cycle_length_weeks, anchor_date, pattern),
)
schedule = await self.get_by_child(child_id)
assert schedule is not None
return schedule
async def get_by_child(
self, child_id: int
) -> PresenceScheduleRecord | None:
"""Return the child's schedule, or None when the child has none.
None is meaningful and UNAMBIGUOUS here: the child exists and
has no schedule row, i.e. the child is present every day
(Feature 05 guardrail). An unknown child id raises ValueError
instead of returning None, because "nonexistent child" must
never be silently read as "present every day".
"""
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
row = await self._database.fetch_one(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
"WHERE child_id = ?",
(child_id,),
)
return _schedule_from_row(row) if row is not None else None
async def delete(self, child_id: int) -> bool:
"""Remove the child's schedule; True if a row was removed.
After deletion the child is present every day (no row means
no restriction). Deleting a schedule the child never had
returns False.
"""
result = await self._database.execute(
"DELETE FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
return result.rowcount > 0
class PresenceOverridesDao:
"""Typed async access to the ``presence_overrides`` table."""
def __init__(self, database: NestQuestDatabase) -> None:
self._database = database
async def create(
self,
child_id: int,
start_date: str,
end_date: str,
is_present: bool,
*,
note: str | None = None,
) -> PresenceOverrideRecord:
"""Insert one override and return the record as stored.
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
child = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?", (child_id,)
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
"VALUES (?, ?, ?, ?, ?)",
(child_id, start_date, end_date, int(is_present), note),
)
override = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE id = ?",
(result.lastrowid,),
)
assert override is not None
return _override_from_row(override)
async def list_by_child_and_range(
self,
child_id: int,
range_start: str,
range_end: str,
) -> list[PresenceOverrideRecord]:
"""Return the child's overrides overlapping [range_start, range_end].
An override overlaps the range when it ends on or after the
range starts and starts on or before the range ends (inclusive
boundary dates, matching the storage model where overrides are
closed date ranges). Ordered by start date.
"""
_validate_date(range_start, "range_start")
_validate_date(range_end, "range_end")
if range_end < range_start:
raise ValueError(
"range_end must be on or after range_start, got "
f"{range_end!r} < {range_start!r}"
)
rows = await self._database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? AND start_date <= ? "
"ORDER BY start_date",
(child_id, range_start, range_end),
)
return [_override_from_row(row) for row in rows]
async def delete(self, override_id: int) -> bool:
"""Remove one override by id; True if a row was removed."""
result = await self._database.execute(
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
+81 -277
View File
@@ -1,44 +1,37 @@
"""Typed DAO layer for the schedule_rules, quest_definitions and
quest_definition_assignees tables.
"""Typed DAO layer for the schedule_rules and task_definitions tables.
All SQL for ``schedule_rules``, ``quest_definitions`` and
``quest_definition_assignees`` lives in this module per the feature
guardrails: callers get typed dataclasses back and never see raw rows
or SQL. Every method is async and runs through
All SQL for ``schedule_rules`` and ``task_definitions`` lives in this
module per the feature guardrails: callers get typed dataclasses back
and never see raw rows or SQL. Every method is async and runs through
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
executor and the event loop never blocks.
Guardrail mapping:
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
referenced by any quest definition is rejected (the done-condition),
referenced by any task definition is rejected (the done-condition),
not cascaded.
- ``quest_definitions`` has no delete path at all: definitions are
- ``task_definitions`` has no delete path at all: definitions are
deactivated, never hard-deleted, and history must survive.
- Assignment is multi-assignee (D-008): :meth:`add_assignee` and
:meth:`remove_assignee` change future instances only — the DAO writes
the assignee table and nothing else; assignment edits never rewrite
- ``set_assignee`` changes future instances only — the DAO writes the
definition row and nothing else; reassignment never rewrites
existing instances or completion history (Feature 06 guardrail).
- Validation of rule well-formedness and child activeness lives partly
in the schema CHECKs (rule-type coherence) and partly in
:meth:`QuestDefinitionsDao.create` (rule existence) and
:meth:`QuestDefinitionsDao.add_assignee` (active child); no
permission checks here, that is the Feature 09 gate's job.
:meth:`TaskDefinitionsDao.create` (active child, rule existence);
no permission checks here, that is the Feature 09 gate's job.
Concurrency: ``delete_rule_if_unreferenced`` and the assignee
mutations perform their checks and writes inside one transaction under
the connection-scoped lock shared with :mod:`.dao_children`, so a
definition referencing the rule (or an assignee appearing) cannot slip
in between check and write on this connection.
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
check and DELETE inside one transaction under the connection-scoped
lock shared with :mod:`.dao_children`, so a definition referencing the
rule cannot appear between check and delete on this connection.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .const import QUEST_WINDOWS
from .dao_children import ChildRecord, _child_from_row, _connection_lock
from .dao_children import _CHILD_COLUMNS
from .dao_children import _connection_lock
from .db import NestQuestDatabase
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
@@ -72,38 +65,6 @@ def _validate_date(value: str, field: str) -> None:
)
_TIME_FORMAT = "%H:%M"
def _validate_time(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict 24-hour HH:MM.
Same strictness policy as _validate_date: parse with the exact
format, then round-trip so '9:30' (non-padded) is rejected. Time
shapes are caller-side policy (the schema stores TEXT): windows'
optional ``due_time`` and definition ``due_time`` both use this.
"""
try:
parsed = datetime.datetime.strptime(value, _TIME_FORMAT).time()
except (TypeError, ValueError):
raise ValueError(
f"{field} must be a 24-hour HH:MM time, got {value!r}"
) from None
if parsed.strftime(_TIME_FORMAT) != value:
raise ValueError(
f"{field} must be a strict HH:MM time, got {value!r}"
)
def _validate_window(window: str) -> None:
"""Raise ValueError unless ``window`` is a ``const`` window name."""
if window not in QUEST_WINDOWS:
raise ValueError(
f"window must be one of {', '.join(QUEST_WINDOWS)}, "
f"got {window!r}"
)
@dataclass(frozen=True)
class ScheduleRuleRecord:
"""One row of ``schedule_rules``."""
@@ -120,37 +81,28 @@ class ScheduleRuleRecord:
@dataclass(frozen=True)
class QuestDefinitionRecord:
"""One row of ``quest_definitions`` (without its assignees)."""
class TaskDefinitionRecord:
"""One row of ``task_definitions``."""
id: int
title: str
description: str | None
icon: str | None
child_id: int
schedule_rule_id: int
due_time: str | None
is_active: bool
created_at: str
@dataclass(frozen=True)
class QuestDefinitionWindowRecord:
"""One row of ``quest_definition_windows``."""
definition_id: int
window: str
due_time: str | None
_RULE_COLUMNS = (
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
"month, start_date, end_date"
)
_DEFINITION_COLUMNS = (
"id, title, description, icon, schedule_rule_id, due_time, "
"id, title, description, icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at"
)
_WINDOW_COLUMNS = "definition_id, window, due_time"
def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
@@ -167,24 +119,17 @@ def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
)
def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
return QuestDefinitionRecord(
def _definition_from_row(row: tuple) -> TaskDefinitionRecord:
return TaskDefinitionRecord(
id=row[0],
title=row[1],
description=row[2],
icon=row[3],
schedule_rule_id=row[4],
due_time=row[5],
is_active=bool(row[6]),
created_at=row[7],
)
def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
return QuestDefinitionWindowRecord(
definition_id=row[0],
window=row[1],
due_time=row[2],
child_id=row[4],
schedule_rule_id=row[5],
due_time=row[6],
is_active=bool(row[7]),
created_at=row[8],
)
@@ -313,7 +258,7 @@ class ScheduleRulesDao:
async with _connection_lock(self._database):
async with self._database.transaction():
row = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions "
"SELECT 1 FROM task_definitions "
"WHERE schedule_rule_id = ? LIMIT 1",
(rule_id,),
)
@@ -326,15 +271,12 @@ class ScheduleRulesDao:
return result.rowcount > 0
class QuestDefinitionsDao:
"""Typed async access to ``quest_definitions`` and its assignees.
class TaskDefinitionsDao:
"""Typed async access to the ``task_definitions`` table.
No delete method exists: definitions are deactivated via
:meth:`set_active`, never hard-deleted, so completion history keeps
its references (feature guardrail). Assignment is multi-assignee
(D-008): the definition row carries no child; assignees live in
``quest_definition_assignees`` and are managed explicitly so the
operations stay separately loggable and permission-gateable.
its references (feature guardrail).
"""
def __init__(self, database: NestQuestDatabase) -> None:
@@ -343,6 +285,7 @@ class QuestDefinitionsDao:
async def create(
self,
title: str,
child_id: int,
schedule_rule_id: int,
created_at: str,
*,
@@ -350,55 +293,53 @@ class QuestDefinitionsDao:
icon: str | None = None,
due_time: str | None = None,
is_active: bool = True,
assignee_child_ids: list[int] | None = None,
) -> QuestDefinitionRecord:
) -> TaskDefinitionRecord:
"""Insert one definition and return the record as stored.
Validates the schedule rule as part of the same serialized
transaction as the insert: the rule must exist AT INSERT TIME —
a rule deleted concurrently cannot slip between the check and
the INSERT. ``assignee_child_ids`` (each child must exist and
be active) is applied in the same transaction, so a definition
can never be observed with a stale or missing assignee set; an
empty list or None creates an unassigned definition, which
callers may fill via :meth:`add_assignee`.
Validates the assignment target as part of the same serialized
transaction as the insert: the child must exist and be active,
and the rule must exist, AT INSERT TIME — a child deactivated
or a rule deleted concurrently cannot slip between the checks
and the INSERT, which would otherwise create an invalid
assignment or surface as a raw FK IntegrityError instead of
the documented ValueError. An INACTIVE child would pass the
schema foreign keys, which is why the activeness check lives
here.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_rule_exists(schedule_rule_id)
for child_id in assignee_child_ids or []:
await self._validate_assignable_child(child_id)
await self._validate_assignable(
child_id, schedule_rule_id
)
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
"INSERT INTO task_definitions (title, description, "
"icon, child_id, schedule_rule_id, due_time, "
"is_active, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
title,
description,
icon,
child_id,
schedule_rule_id,
due_time,
int(is_active),
created_at,
),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids or []:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
definition = await self.get(definition_id)
definition = await self.get(result.lastrowid)
assert definition is not None
return definition
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
async def _validate_assignable(
self, child_id: int, schedule_rule_id: int
) -> None:
"""Raise ValueError unless child (active) and rule both exist.
MUST be called inside the connection lock so the verdict cannot
go stale before the caller's INSERT commits.
"""
await self._validate_assignable_child(child_id)
rule = await self._database.fetch_one(
"SELECT 1 FROM schedule_rules WHERE id = ?",
(schedule_rule_id,),
@@ -408,35 +349,28 @@ class QuestDefinitionsDao:
f"schedule rule {schedule_rule_id} does not exist"
)
async def get(self, definition_id: int) -> QuestDefinitionRecord | None:
async def get(self, definition_id: int) -> TaskDefinitionRecord | None:
"""Return the definition with ``definition_id``, or None."""
row = await self._database.fetch_one(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE id = ?",
(definition_id,),
)
return _definition_from_row(row) if row is not None else None
async def list_by_child(self, child_id: int) -> list[QuestDefinitionRecord]:
"""Return all definitions assigned to ``child_id``, newest first.
Assignment is read through ``quest_definition_assignees``
(D-008): a definition appears once per child regardless of how
many assignees share it.
"""
async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]:
"""Return all definitions assigned to ``child_id``, newest first."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
"WHERE id IN (SELECT definition_id "
"FROM quest_definition_assignees WHERE child_id = ?) "
"ORDER BY id DESC",
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE child_id = ? ORDER BY id DESC",
(child_id,),
)
return [_definition_from_row(row) for row in rows]
async def list_active(self) -> list[QuestDefinitionRecord]:
async def list_active(self) -> list[TaskDefinitionRecord]:
"""Return all active definitions, oldest first (stable order)."""
rows = await self._database.fetch_all(
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions "
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
"WHERE is_active = 1 ORDER BY id"
)
return [_definition_from_row(row) for row in rows]
@@ -457,9 +391,8 @@ class QuestDefinitionsDao:
SQL NULL, so callers can remove optional metadata
(description, icon, due_time). Assignment and activation are
deliberately NOT settable here: they have their own explicit
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
:meth:`set_active`) so they stay separately loggable and
permission-gateable.
operations (:meth:`set_assignee`, :meth:`set_active`) so they
stay separately loggable and permission-gateable.
"""
assignments: list[str] = []
parameters: list[object] = []
@@ -476,7 +409,7 @@ class QuestDefinitionsDao:
return 0
parameters.append(definition_id)
result = await self._database.execute(
f"UPDATE quest_definitions SET {', '.join(assignments)} "
f"UPDATE task_definitions SET {', '.join(assignments)} "
"WHERE id = ?",
tuple(parameters),
)
@@ -489,166 +422,37 @@ class QuestDefinitionsDao:
existing instances and completion history untouched.
"""
result = await self._database.execute(
"UPDATE quest_definitions SET is_active = ? WHERE id = ?",
"UPDATE task_definitions SET is_active = ? WHERE id = ?",
(int(is_active), definition_id),
)
return result.rowcount
async def add_assignee(
async def set_assignee(
self, definition_id: int, child_id: int
) -> None:
"""Assign ``child_id`` to the definition (idempotent).
) -> int:
"""Reassign the definition to ``child_id``; rows updated (0 if absent).
Validates the assignee inside the same serialized transaction
as the INSERT (existing and active AT ASSIGNMENT TIME), so a
child deactivated concurrently cannot become an assignee.
Assigning an already-assigned child is a no-op, mirroring the
composite primary key's storage-level guarantee. Changes
future instances only: already-generated instances and
completion history are never rewritten here.
Validates the new assignee inside the same serialized
transaction as the UPDATE (existing and active AT ASSIGNMENT
TIME), so a child deactivated concurrently cannot become the
assignee. Changes future instances only: already-generated
instances and completion history are never rewritten here.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
await self._validate_assignable_child(child_id)
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
result = await self._database.execute(
"UPDATE task_definitions SET child_id = ? "
"WHERE id = ?",
(child_id, definition_id),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?) "
"ON CONFLICT (definition_id, child_id) DO NOTHING",
(definition_id, child_id),
)
async def remove_assignee(
self, definition_id: int, child_id: int
) -> bool:
"""Remove the assignee link; True when a row was removed.
Future materialization stops for this (definition, child) pair;
existing instances and completion history are never touched
here (Feature 06 guardrail).
"""
async with _connection_lock(self._database):
result = await self._database.execute(
"DELETE FROM quest_definition_assignees "
"WHERE definition_id = ? AND child_id = ?",
(definition_id, child_id),
)
return result.rowcount > 0
async def list_assignees(self, definition_id: int) -> list[ChildRecord]:
"""Return the definition's assignees as child records.
Ordered by the children table's stable display order
(``sort_order``, then id) so callers render a deterministic
roster. A definition with no assignees returns [].
"""
rows = await self._database.fetch_all(
f"SELECT {_CHILD_COLUMNS} FROM children "
"WHERE id IN (SELECT child_id "
"FROM quest_definition_assignees WHERE definition_id = ?) "
"ORDER BY sort_order, id",
(definition_id,),
)
return [_child_from_row(row) for row in rows]
async def upsert_window(
self,
definition_id: int,
window: str,
*,
due_time: str | None = None,
) -> QuestDefinitionWindowRecord:
"""Declare (or re-declare) ``window`` on the definition.
The composite primary key makes the write idempotent per
(definition, window): re-declaring updates ``due_time`` in
place instead of duplicating. The window name must be one of
``const.QUEST_WINDOWS`` (the schema CHECK backs this up) and a
provided ``due_time`` must be strict 24-hour HH:MM. The
definition must exist — validated under the connection lock so
the verdict cannot go stale before the write.
"""
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) VALUES (?, ?, ?) "
"ON CONFLICT (definition_id, window) DO UPDATE SET "
"due_time = excluded.due_time",
(definition_id, window, due_time),
)
row = await self._database.fetch_one(
f"SELECT {_WINDOW_COLUMNS} "
"FROM quest_definition_windows "
"WHERE definition_id = ? AND window = ?",
(definition_id, window),
)
assert row is not None
return _window_from_row(row)
async def list_windows(
self, definition_id: int
) -> list[QuestDefinitionWindowRecord]:
"""Return the definition's windows in ``const`` order.
Ordered by the canonical window sequence (morning, afternoon,
evening) rather than insertion order, so callers always render
the Quest Log's three columns consistently. A definition with
no windows returns [].
"""
rows = await self._database.fetch_all(
f"SELECT {_WINDOW_COLUMNS} FROM quest_definition_windows "
"WHERE definition_id = ?",
(definition_id,),
)
by_name = {row[1]: _window_from_row(row) for row in rows}
return [
by_name[name]
for name in QUEST_WINDOWS
if name in by_name
]
async def remove_window(
self, definition_id: int, window: str
) -> bool:
"""Remove the window declaration; True when a row was removed.
Future materialization stops producing instances for this
(definition, window); existing instances and completion history
are never touched here (Feature 06 guardrail).
"""
_validate_window(window)
async with _connection_lock(self._database):
result = await self._database.execute(
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ? AND window = ?",
(definition_id, window),
)
return result.rowcount > 0
return result.rowcount
async def _validate_assignable_child(self, child_id: int) -> None:
"""Raise ValueError unless the child exists and is active.
MUST be called inside the connection lock so the verdict cannot
go stale before the caller's INSERT commits.
go stale before the caller's UPDATE commits.
"""
child = await self._database.fetch_one(
"SELECT is_active FROM children WHERE id = ?", (child_id,)
+4 -6
View File
@@ -1,14 +1,12 @@
{
"domain": "nestquest",
"name": "NestQuest",
"codeowners": [
"@overseer"
],
"codeowners": ["@overseer"],
"config_flow": true,
"documentation": "https://github.com/talon2king/NestQuest",
"documentation": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
"issue_tracker": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest/issues",
"requirements": [],
"version": "0.3.0"
"version": "0.1.0"
}
+35 -230
View File
@@ -14,28 +14,6 @@ Version 0 means "the file exists but carries no NestQuest schema yet"
the runner against version 0 applies migration 1, which applies the
full v1 DDL from :mod:`.schema` and stamps version 1.
Migration 1 was rewritten pre-release (D-007): nothing was ever
shipped, so the v1 DDL now creates the ``quest_*`` tables directly
instead of the legacy ``task_*`` spellings, and no compatibility shim
exists. Dev machines that already hold a version-1 database with
``task_*`` tables are handled by migration 2, which detects the legacy
table names and ``ALTER TABLE ... RENAME TO``s them to their current
names before anything else assumes the new model. Migration 3 brings
pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Each callable migration
runs inside its own transaction together with its version stamp, so a
crash mid-step rolls the statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
exist for steps whose SQL depends on the database's current state (a
legacy rename only fires when a legacy table is present; a rebuild only
when the old column shape is present) and own their transaction so they
can bracket it with connection pragmas the runner's uniform transaction
cannot express.
Each migration's statements and its version stamp run inside ONE
transaction, and the stamp is written as DELETE + INSERT rather than
UPDATE because a version-0 database has no version row to update yet.
@@ -65,27 +43,13 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable, Sequence
from typing import Union
from collections.abc import Sequence
from .db import NestQuestDatabase
from .schema import (
QUEST_DEFINITIONS_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
)
from .schema import SCHEMA_V1_STATEMENTS
LOGGER = logging.getLogger(__name__)
#: One migration entry: either an ordered sequence of SQL statements or
#: an async callable taking ``(database, target_version)`` that manages
#: its own transaction and stamps via :func:`stamp_schema_version`.
MigrationStep = Union[
Sequence[str],
Callable[[NestQuestDatabase, int], Awaitable[None]],
]
#: Metadata table holding the applied schema version. Prefixed with the
#: integration domain so a config directory shared with other tools can
#: never collide with a foreign ``schema_version`` table.
@@ -109,158 +73,10 @@ VERSION_TABLE_DDL = f"""
#: version ``n-1`` to version ``n``.
MIGRATION_1_V1_DDL: list[str] = list(SCHEMA_V1_STATEMENTS)
#: The assignees table DDL, shared with migration 1 via the schema
#: module so a rebuilt and a freshly created table cannot drift.
_ASSIGNEES_TABLE_DDL: str = SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL[0]
#: Legacy (pre-D-007) table name -> current table name. Order matters:
#: the parent table renames first so the child's foreign key reference
#: is rewritten while its parent's new name is already in place.
LEGACY_TASK_TABLE_RENAMES: tuple[tuple[str, str], ...] = (
("task_definitions", "quest_definitions"),
("task_instances", "quest_instances"),
)
async def stamp_schema_version(
database: NestQuestDatabase, version: int
) -> None:
"""Write the schema version stamp (inside the caller's transaction).
INSERT keyed on the singleton row (id = 1) with an upsert: a fresh
file has no row yet, so UPDATE alone would silently match zero rows
there and leave a migrated-but-unstamped database.
"""
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = excluded.version",
(version,),
)
async def _table_has_column(
database: NestQuestDatabase, table: str, column: str
) -> bool:
"""Return True when ``table`` currently carries ``column``."""
rows = await database.fetch_all(f"PRAGMA table_info({table})")
return any(row[1] == column for row in rows)
async def _rename_legacy_task_tables(
database: NestQuestDatabase, target_version: int
) -> None:
"""Rename any surviving ``task_*`` tables to their ``quest_*`` names.
Dev-machines-only path (D-007): a database stamped version 1 by the
pre-rename runner holds ``task_definitions``/``task_instances``.
Each legacy name still present is renamed; fresh databases find
nothing and this step is a no-op. Modern SQLite rewrites foreign
key clauses in other tables to follow the rename, so the renamed
schema is indistinguishable from a freshly created one apart from
internal autoindex names. Runs in its own transaction together
with the version stamp, so a crash rolls both back.
"""
async with database.transaction():
for legacy_name, current_name in LEGACY_TASK_TABLE_RENAMES:
row = await database.fetch_one(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = ?",
(legacy_name,),
)
if row is not None:
await database.execute(
f'ALTER TABLE "{legacy_name}" RENAME TO "{current_name}"'
)
LOGGER.info(
"Renamed legacy table %s to %s",
legacy_name,
current_name,
)
await stamp_schema_version(database, target_version)
async def _add_definition_assignees(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring definitions to the multi-assignee model (D-008).
Two shapes arrive here:
- Fresh or renamed databases whose ``quest_definitions`` already
matches the current canonical DDL (no ``child_id`` column): only
the assignees table needs creating (``IF NOT EXISTS`` is a no-op
when migration 1 already made it).
- Databases stamped at version 2 whose ``quest_definitions`` still
carries the single ``child_id`` column: the table is rebuilt via
SQLite's canonical procedure — create the new table, copy rows,
move each definition's assignee into
``quest_definition_assignees``, drop the old table, rename — so
``DROP COLUMN``'s foreign-key restriction never applies and no
definition or instance row is lost.
The copy runs with ``foreign_keys = OFF`` because dropping the old
table under enforcement would fire the implicit DELETE and violate
the ``quest_instances`` foreign key; ``PRAGMA foreign_key_check``
runs inside the same transaction before the stamp, so a corrupt
copy aborts the whole migration instead of persisting. The pragma
is a connection-level no-op inside a transaction, hence the
off/transaction/on bracket.
"""
legacy_child_id = await _table_has_column(
database, "quest_definitions", "child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
await database.execute(_ASSIGNEES_TABLE_DDL)
if legacy_child_id:
await database.execute(
"CREATE TABLE quest_definitions_rebuilt "
f"{QUEST_DEFINITIONS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO quest_definitions_rebuilt "
"(id, title, description, icon, schedule_rule_id, "
"due_time, is_active, created_at) "
"SELECT id, title, description, icon, "
"schedule_rule_id, due_time, is_active, created_at "
"FROM quest_definitions ORDER BY id"
)
await database.execute(
"INSERT OR IGNORE INTO quest_definition_assignees "
"(definition_id, child_id) "
"SELECT id, child_id FROM quest_definitions"
)
await database.execute("DROP TABLE quest_definitions")
await database.execute(
"ALTER TABLE quest_definitions_rebuilt "
"RENAME TO quest_definitions"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"definition rebuild produced foreign-key "
f"violations: {violations!r}"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
MIGRATIONS: Sequence[MigrationStep] = [
#: entry, add the next one instead.
MIGRATIONS: Sequence[Sequence[str]] = [
MIGRATION_1_V1_DDL,
_rename_legacy_task_tables,
_add_definition_assignees,
# Migration 4 (D-008): the windows table is a plain new table — a
# CREATE IF NOT EXISTS suffices for both fresh databases (which
# already made it in migration 1) and pre-window databases.
list(SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL),
]
@@ -324,7 +140,7 @@ def _migration_lock(database: NestQuestDatabase) -> asyncio.Lock:
async def apply_migrations(
database: NestQuestDatabase,
migrations: Sequence[MigrationStep] = MIGRATIONS,
migrations: Sequence[Sequence[str]] = MIGRATIONS,
) -> int:
"""Bring the database schema up to the latest version.
@@ -373,18 +189,17 @@ async def apply_migrations(
for target in range(current + 1, latest + 1):
applied = False
step = migrations[target - 1]
if callable(step):
# Callable migrations own their transaction: some steps
# (table rebuilds) must run connection pragmas such as
# ``foreign_keys = OFF`` BEFORE opening one, which the
# runner's uniform transaction cannot express, and they
# stamp inside their own transaction so a failure rolls
# the statements and the version stamp back together.
# The version is re-read here under the migration lock:
# runners on one connection are serialized by it, so
# the version cannot change between this read and the
# callable's commit.
async with database.transaction():
# Re-read the version INSIDE the write transaction: the
# outer read was advisory, and the wrapper serializes
# individual statements, not the read-decide-apply
# decision. Under the transaction's lock the version
# cannot change between the read and the stamp, and the
# loser of an interleaved race (a queued second caller
# re-running apply_migrations after the winner already
# migrated) re-reads here, sees the winner's stamp, and
# skips instead of re-applying a non-idempotent
# migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
@@ -393,39 +208,29 @@ async def apply_migrations(
f"{latest}); downgrades are not supported"
)
if in_tx_version < target:
await step(database, target)
for sql in migrations[target - 1]:
await database.execute(sql)
# INSERT keyed on the singleton row (id = 1): a
# fresh file has no row yet, and migration 1 is the
# only migration a version-0 database can run, so
# the row is seeded by the same statement batch
# when needed. UPDATE alone would silently match
# zero rows there.
await database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) "
"VALUES (1, ?) "
"ON CONFLICT (id) DO UPDATE SET version = "
"excluded.version",
(target,),
)
applied = True
else:
async with database.transaction():
# Re-read the version INSIDE the write transaction:
# the outer read was advisory, and the wrapper
# serializes individual statements, not the
# read-decide-apply decision. Under the
# transaction's lock the version cannot change
# between the read and the stamp, and the loser of
# an interleaved race (a queued second caller
# re-running apply_migrations after the winner
# already migrated) re-reads here, sees the
# winner's stamp, and skips instead of re-applying
# a non-idempotent migration with a stale version.
in_tx_version = await read_schema_version(database)
if in_tx_version > latest:
raise RuntimeError(
f"Database schema version {in_tx_version} is "
f"newer than this integration understands "
f"(latest known: {latest}); downgrades are "
f"not supported"
)
if in_tx_version < target:
for sql in step:
await database.execute(sql)
await stamp_schema_version(database, target)
applied = True
applied_statements = len(step)
if applied:
LOGGER.info(
"NestQuest schema migrated to version %d",
"NestQuest schema migrated to version %d "
"(%d statement%s)",
target,
len(migrations[target - 1]),
"s" if len(migrations[target - 1]) != 1 else "",
)
return latest
-621
View File
@@ -1,621 +0,0 @@
"""The ScheduleRule model: rule types, validation, and serialization.
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
frozen dataclass with no Home Assistant and no database imports. The
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
serializes its dict form into the ``schedule_rules`` table.
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
Tuesday of every month") are distinct types rather than one monthly
type with optional fields, so validation is total: a rule either
constructs, or raises :class:`RuleValidationError` naming the offending
field. Every type carries ONLY the fields its shape needs; carrying
any other shape's field is a construction error.
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
``storage_value`` maps model types onto the schema's CHECK values —
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
disambiguated by which fields are populated; CUSTOM_DAYS stores as
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
table stores (one element for MONTHLY_WEEKDAY, which names the
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
carried through from_dict/to_dict for losslessness and folded into
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
model-side lossless pair; the DB mapping lives in the DAO layer and is
exercised there.
Validation policy (fail fast at construction — the schema's CHECK
constraints are a second line of defense, but invalid rules must never
reach storage):
- ``interval`` >= 1 for every rule.
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
- ``month`` 1..12 is required for YEARLY.
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
"""
from __future__ import annotations
import datetime
import enum
from dataclasses import dataclass, field
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
_DATE_FORMAT = "%Y-%m-%d"
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
class RuleType(enum.Enum):
"""The six recurrence shapes the engine supports.
``storage_value`` is the string written to the ``schedule_rules``
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
disambiguated by which fields are populated.
"""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY_DAY = "monthly_day"
MONTHLY_WEEKDAY = "monthly_weekday"
YEARLY = "yearly"
CUSTOM_DAYS = "custom_days"
@property
def storage_value(self) -> str:
"""The schema-level rule_type string this model type maps to."""
return {
RuleType.DAILY: "daily",
RuleType.WEEKLY: "weekly",
RuleType.MONTHLY_DAY: "monthly",
RuleType.MONTHLY_WEEKDAY: "monthly",
RuleType.YEARLY: "yearly",
RuleType.CUSTOM_DAYS: "custom",
}[self]
class RuleValidationError(ValueError):
"""Raised when a ScheduleRule field combination is invalid."""
def _validate_date(value: str, field_name: str) -> None:
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise RuleValidationError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise RuleValidationError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
def _validate_weekday_set(weekday_set) -> frozenset[int]:
"""Return a normalized frozenset or raise with a clear message.
Sequence entries are validated BEFORE set() coercion: a list like
[0, False] must not collapse the boolean into 0, and unhashable
entries must raise RuleValidationError, never a raw TypeError.
"""
if isinstance(weekday_set, (list, tuple)):
for entry in weekday_set:
# _is_plain_int: bool and int subclasses (potentially
# unhashable) are rejected before set() coercion.
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
if not isinstance(weekday_set, (set, frozenset)):
raise RuleValidationError(
f"weekday_set must be a set of weekday integers, got "
f"{type(weekday_set).__name__}"
)
if not weekday_set:
raise RuleValidationError(
"weekday_set must not be empty for weekly/custom rules"
)
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
if not 0 <= entry <= 6:
raise RuleValidationError(
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
f"got {entry!r}"
)
return frozenset(weekday_set)
def _is_plain_int(value) -> bool:
"""True only for real ints: bool excluded AND int subclasses too
(an unhashable int subclass would break the frozen-hashable model)."""
return type(value) is int
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
"""Validate the MONTHLY_WEEKDAY position fields."""
if nth_weekday is None or not _is_plain_int(nth_weekday):
raise RuleValidationError(
"nth_weekday must be an integer for monthly_weekday rules, "
f"got {nth_weekday!r}"
)
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
raise RuleValidationError(
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
)
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
raise RuleValidationError(
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
f"(Sunday), got {weekday!r}"
)
@dataclass(frozen=True)
class ScheduleRule:
"""An immutable, validated recurrence rule (see module docstring).
Construct with keyword arguments; invalid combinations raise
:class:`RuleValidationError` at construction time. Fields map
one-to-one onto the ``schedule_rules`` table columns, with
``nth_weekday_weekday`` as the model-only weekday companion to
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
boundary).
"""
rule_type: RuleType
interval: int = 1
weekday_set: frozenset[int] | None = None
day_of_month: int | None = None
nth_weekday: int | None = None
nth_weekday_weekday: int | None = None
month: int | None = None
start_date: str = "1970-01-01"
end_date: str | None = None
def __post_init__(self) -> None:
# Normalize a string rule_type into the enum; anything else
# that is not already a RuleType is rejected.
if isinstance(self.rule_type, str):
# EXACT model names only: no whitespace stripping, no storage
# aliases. The DAO maps storage strings to model fields when
# reading rows; the model boundary stays unambiguous.
candidates = {rt.name.lower(): rt for rt in RuleType}
if self.rule_type in candidates:
object.__setattr__(
self, "rule_type", candidates[self.rule_type]
)
else:
raise RuleValidationError(
f"rule_type must be a RuleType or one of "
f"{sorted(candidates)}, got {self.rule_type!r}"
)
if not isinstance(self.rule_type, RuleType):
raise RuleValidationError(
f"rule_type must be a RuleType or one of its string "
f"values, got {self.rule_type!r}"
)
if not _is_plain_int(self.interval):
raise RuleValidationError(
f"interval must be an integer >= 1, got {self.interval!r}"
)
if self.interval < 1:
raise RuleValidationError(
f"interval must be >= 1, got {self.interval!r}"
)
_validate_date(self.start_date, "start_date")
if self.end_date is not None:
_validate_date(self.end_date, "end_date")
if self.end_date < self.start_date:
raise RuleValidationError(
f"end_date {self.end_date!r} must be on or after "
f"start_date {self.start_date!r}"
)
# Type-exclusive field policy, enforced for every type: build
# the dict of present fields, then reject every field the shape
# must not carry (weekday_set present is detectable via None vs
# set even after normalization below).
shape = self.rule_type
if shape is RuleType.DAILY:
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.WEEKLY:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.CUSTOM_DAYS:
forbidden_fields = (
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
)
elif shape is RuleType.MONTHLY_DAY:
forbidden_fields = ("weekday_set", "nth_weekday",
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
forbidden_fields = (
"weekday_set",
"nth_weekday",
"nth_weekday_weekday",
)
present_forbidden = [
name
for name in forbidden_fields
if getattr(self, name) is not None
]
if present_forbidden:
raise RuleValidationError(
f"{shape.value} rules must not set "
f"{', '.join(sorted(present_forbidden))}"
)
# Required fields per shape.
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
normalized = _validate_weekday_set(self.weekday_set)
object.__setattr__(self, "weekday_set", normalized)
elif shape is RuleType.MONTHLY_DAY:
if self.day_of_month is None or not _is_plain_int(
self.day_of_month
):
raise RuleValidationError(
"monthly_day rules require day_of_month (1..31)"
)
if not 1 <= self.day_of_month <= 31:
raise RuleValidationError(
f"day_of_month must be 1..31, got {self.day_of_month!r}"
)
elif shape is RuleType.MONTHLY_WEEKDAY:
_validate_monthly_weekday(
self.nth_weekday, self.nth_weekday_weekday
)
elif shape is RuleType.YEARLY:
if self.month is None or not _is_plain_int(self.month):
raise RuleValidationError(
"yearly rules require month (1..12)"
)
if not 1 <= self.month <= 12:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
weekday travels inside it (appended) plus
``nth_weekday_weekday`` so the pair round-trips without
ambiguity at the model layer.
"""
return {
"rule_type": self.rule_type.name.lower(), # model name
"interval": self.interval,
"weekday_set": (
sorted(self.weekday_set) if self.weekday_set is not None
else None
),
"day_of_month": self.day_of_month,
"nth_weekday": self.nth_weekday,
"nth_weekday_weekday": self.nth_weekday_weekday,
"month": self.month,
"start_date": self.start_date,
"end_date": self.end_date,
}
@classmethod
def from_dict(cls, data: dict) -> "ScheduleRule":
"""Rebuild a rule from :meth:`to_dict` output.
Raises :class:`RuleValidationError` on unknown keys, missing
keys, or invalid combinations — never a raw TypeError.
"""
if not isinstance(data, dict):
raise RuleValidationError(
f"rule data must be a dict, got {type(data).__name__}"
)
allowed = {
"rule_type",
"interval",
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
"month",
"start_date",
"end_date",
}
unknown = [key for key in data if key not in allowed]
if unknown:
raise RuleValidationError(
f"unknown rule fields: {sorted(unknown, key=repr)}"
)
if "rule_type" not in data:
raise RuleValidationError("missing required rule field: rule_type")
weekday_set = data.get("weekday_set")
if isinstance(weekday_set, (list, tuple)):
# Validate entries as plain ints BEFORE set() coercion, so a
# list like [0, False] cannot collapse False into 0 and an
# unhashable/int-subclass entry cannot leak a raw TypeError.
for entry in weekday_set:
if not _is_plain_int(entry):
raise RuleValidationError(
f"weekday_set entries must be plain integers, got "
f"{entry!r}"
)
weekday_set = set(weekday_set)
return cls(
rule_type=data["rule_type"],
interval=data.get("interval", 1),
weekday_set=weekday_set,
day_of_month=data.get("day_of_month"),
nth_weekday=data.get("nth_weekday"),
nth_weekday_weekday=data.get("nth_weekday_weekday"),
month=data.get("month"),
start_date=data.get("start_date", "1970-01-01"),
end_date=data.get("end_date"),
)
# ---------------------------------------------------------------------------
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
# ---------------------------------------------------------------------------
def _parse_date(value: str) -> datetime.date:
"""Parse a strict ISO date already validated by the model."""
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
"""Day offset from the rule's anchor date to ``target``.
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
first day the rule CAN fire. A negative offset means the target
precedes the rule's window entirely.
"""
return (target - _parse_date(rule.start_date)).days
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
"""True when ``target`` is inside the rule's [start, end] window."""
if target < _parse_date(rule.start_date):
return False
if rule.end_date is not None and target > _parse_date(rule.end_date):
return False
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
Pure function: no I/O, no timezone awareness, no mutation. Dates
are plain calendar dates (Feature 04 guardrail); the caller decides
the timezone that produced them.
"""
# Dispatch FIRST: an unimplemented shape must raise loudly even for
# dates outside the window, never silently evaluate to False.
shape = rule.rule_type
if shape not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurs_on for {shape} lands with its own engine task "
"(Features 04 task sequence)"
)
if not _within_window(rule, target):
return False
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
# parity on January 1st; anchor arithmetic does not).
anchor = _parse_date(rule.start_date)
if shape is RuleType.CUSTOM_DAYS:
interval = 1
else:
interval = rule.interval
week_offset = (target - anchor).days // 7
if week_offset % interval != 0:
return False
return target.weekday() in rule.weekday_set
def occurrences_between(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Return every date string in [start, end] the rule fires on.
Inclusive on both ends. Raises RuleValidationError on malformed or
inverted bounds; a window entirely outside [rule.start_date,
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
"engine task (Features 04 task sequence)"
)
_validate_date(start, "start")
_validate_date(end, "end")
start_date = _parse_date(start)
end_date = _parse_date(end)
if end_date < start_date:
raise RuleValidationError(
f"end {end!r} must be on or after start {start!r}"
)
rule_start = _parse_date(rule.start_date)
if end_date < rule_start:
return []
# Clamp the walk to the rule's own window and the queried range.
effective_start = max(start_date, rule_start)
effective_end = end_date
if rule.end_date is not None:
effective_end = min(effective_end, _parse_date(rule.end_date))
results: list[str] = []
cursor = effective_start
while cursor <= effective_end:
if occurs_on(rule, cursor):
results.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return results
+18 -65
View File
@@ -86,25 +86,7 @@ date range; a single-day override stores the same date in both columns.
``is_present`` 0/1 marks the child absent/present for the whole range;
``note`` is optional free text.
Multi-assignee definitions (D-008): ``quest_definitions`` carries no
``child_id`` column. Assignment lives in
``quest_definition_assignees``, one row per (definition, child) with a
composite primary key and foreign keys to the definition and the child,
so "brush teeth" is one definition covering three children. The
migration runner rebuilds a pre-D-008 table into this shape and copies
its single-assignee column across. The composite primary key makes
duplicate assignment impossible at the storage layer.
Quest windows (D-008): ``quest_definition_windows`` declares which day
windows a definition spans — one row per (definition, window), the
composite primary key making a window idempotent per definition.
``window`` is CHECK-constrained to the three spellings ``const`` owns;
``due_time`` optionally pins a due time inside the window and is
caller-validated (strict HH:MM) by the DAO layer, matching the date
policy above. The window clock ranges live in ``const.py``, not here:
this layer stores what the caller declares and never interprets clocks.
Quest instances: ``quest_instances`` deliberately carries NO completion
Task instances: ``task_instances`` deliberately carries NO completion
status column. An instance's current state derives from the latest row
in ``completion_events`` (Feature 08), so materialization (Feature 07)
can insert instances without knowing anything about completion and the
@@ -121,7 +103,7 @@ stamps one coherent time).
Uniqueness: the (definition_id, due_date) pair is declared as a
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
index (``sqlite_autoindex_quest_instances_1``) — the required "unique
index (``sqlite_autoindex_task_instances_1``) — the required "unique
index prevents duplicate instances" — and the constraint form cannot be
silently dropped the way a standalone index can, so the duplicate door
stays shut. Idempotent materialization keys on exactly this pair. No
@@ -146,7 +128,7 @@ representable — missed is a derived state announced on the HA event bus
(Feature 10), never an appended event.
``child_id`` is deliberately denormalized (it is also derivable via
``quest_instances``): history reporting (Feature 13) filters and rates
``task_instances``): history reporting (Feature 13) filters and rates
per child directly against the log, and the log should stay
self-describing without joins.
@@ -237,45 +219,18 @@ SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
""",
]
#: Column body of ``quest_definitions``, shared between the v1 DDL
#: below and the multi-assignee rebuild migration, which must recreate
#: the table in exactly this shape (D-008: one definition, many
#: assignees via ``quest_definition_assignees`` — no ``child_id``
#: column on the definition itself).
QUEST_DEFINITIONS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)"""
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS quest_definitions {QUEST_DEFINITIONS_TABLE_SQL}
""",
]
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL: list[str] = [
SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_assignees (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
CREATE TABLE IF NOT EXISTS task_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
PRIMARY KEY (definition_id, child_id)
)
""",
]
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_definition_windows (
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
window TEXT NOT NULL CHECK (window IN ('morning', 'afternoon', 'evening')),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
PRIMARY KEY (definition_id, window)
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
created_at TEXT NOT NULL
)
""",
]
@@ -316,11 +271,11 @@ SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
""",
]
SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS quest_instances (
CREATE TABLE IF NOT EXISTS task_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES quest_definitions(id),
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
@@ -334,7 +289,7 @@ SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
@@ -360,12 +315,10 @@ SCHEMA_V1_STATEMENTS: list[str] = [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
]
-136
View File
@@ -1,136 +0,0 @@
# Admin spec — parent phone card
Screens `1d`–`1i`. One card, `nestquest-admin-card`, four tabs. Reference viewport
390×844. Plain CubeCraft styling and plain domain words — no parchment, no Cinzel, no
quest vocabulary in this surface.
## 1. Shell
- Page `--nq-a-page`; cards `--nq-a-surface` with 1px `--nq-a-border`.
- **Header** white, 1px bottom border: screen title 24px/800, tracking −.02em, plus a
12px/600 `--nq-a-ink-secondary` sub-line. Optional trailing action (icon square 36px,
or a gradient `New` button).
- **Tab bar** absolute bottom, `--nq-a-tabbar-height` 70px + 12px bottom padding, white,
1px top border, 4 items: `house` Today · `list-checks` Tasks · `calendar-days` Schedule ·
`history` History. Active icon and label `--nq-brand-blue` at 700; inactive icon
`--nq-a-ink-tertiary`, label `--nq-a-ink-secondary` 600.
- **Every scroll column ends with 92px bottom padding** so content clears the tab bar.
This was a real defect in review — do not omit it.
- Two densities exist. Ship **compact**; `--nq-a-density-*` carries the roomy values.
- Everything this card mutates goes through a NestQuest service, which enforces the admin
allowlist server-side. The card must assume it can be rendered by a non-admin and still
be safe — hiding controls is not the boundary (D-003).
## 2. Today (1d compact, 1h roomy)
### 2.1 Header
Title `Today`; sub `<weekday>, <Month D> · cycle day N of 14`; trailing `settings` square.
### 2.2 Stat row
Three equal cards, 8px gap, padding 12px: numeral 22px/800 (done in `--nq-brand-blue`,
remaining in `--nq-a-ink`, overdue in `--nq-a-danger`), label 10px/600 uppercase tracking
.08em `--nq-a-ink-secondary` — `Done`, `Remaining`, `Overdue`.
### 2.3 Child rows
One card, rows divided by `--nq-a-divider`, row padding 11px 13px:
30px gradient avatar square (radius 7px, initial 13px/800 white) · name 14px/700 ·
meta 11px/600 (`3 of 7 · 1 overdue`; all-done in `--nq-a-success`; away reads
`Away · returns <Mon D>`) · 82×6px progress track with gradient fill. An away child's
avatar is flat `#9898A8` and the row carries a lowercase `override` pill when the absence
comes from a presence override rather than the pattern.
### 2.4 Roomy variant (1h)
Header becomes a hero: kicker 11px/700 uppercase, `8 of 12 done` 32px/800, 10px gradient
progress bar, 13px/600 sub-line. Each child gets its own 12px-radius card with
`0 4px 12px rgba(0,0,0,.06)`, 48px avatar, 19px/800 name, right-aligned `3/7` +
status line, and its overdue item inlined as a `--nq-a-danger-bg` row with a
`Mark done` action.
### 2.5 Needs attention
Section label 11px/700 uppercase, then one card of event rows: Lucide glyph 18px ·
title `<Quest> · <Child>` 13px/700 · meta 11px/600 (`Overdue since 9:00 AM` in
`--nq-a-danger`; `Completed 7:04 AM from panel` in `--nq-a-ink-secondary`) · trailing
text action `Mark done` (`--nq-brand-blue`) or `Undo` (`--nq-brand-purple`).
### 2.6 Permission note
An info strip (`--nq-a-info-bg`, `info` glyph, 11.5px/600 `--nq-a-info-ink`):
"Undo is admin-only. A panel tap can never reverse a completion." Keep this copy — it is
the one place the security model is explained to the user.
## 3. Definitions (1e)
### 3.1 List
Header `Tasks` / `7 definitions · 3 children` + gradient `New` button. Filter chips row
(All / one per child): active `#18181D` fill white text, inactive white with border.
Rows: glyph 17px · title 13.5px/700 · meta 11px/600 `<assignees> · <recurrence> · <windows>`
· `chevron-right`. An inactive definition renders the whole row at
`--nq-a-ink-tertiary` with `· inactive` in the meta — never hard-deleted (glossary).
The row being edited takes `--nq-a-selected` with brand-blue glyph, `Editing` meta and
`chevron-down`.
### 3.2 Edit sheet
Bottom sheet, `--nq-a-radius-sheet`, `--nq-a-sheet-shadow`, 38×4px grabber, padding
14px 18px 24px. Title 19px/800. Fields, 11px gap, each with a 10px/700 uppercase label:
| Field | Control |
|---|---|
| Assigned children | multi-select row → sheet; shows `Declan` or `All three` |
| Repeats | select (Daily / Weekly / Monthly / Yearly / Custom + interval) |
| Due time | time field per window |
| Window | 3-up segmented, **multi-select** — a definition may own several |
| Skip on away days | toggle, gradient when on, sub "No instance when the child is absent" |
Footer: `Cancel` (flex 1, bordered) + `Save changes` (flex 2, gradient), both 13px vertical
padding. Sheet enters over the dimmed list in `--nq-dur-modal`.
### 3.3 Icon picker
Each definition carries a Lucide glyph, shown on both surfaces. **The allowed subset is not
yet defined** — pick 24–40 household-relevant glyphs and ship them as a grid picker.
## 4. Schedule (1f)
- Header `Schedule` / `2-week cycle · anchor <weekday>, <Mon D>`.
- **Month grid** card: month label 11px/700 uppercase + chevron pair; weekday initials
10px/700 `--nq-a-ink-tertiary`; day cells `aspect-ratio: 1`, radius 6px —
rotation-home days `--nq-a-selected` with `#201F7A` numerals, away days
`--nq-a-page` with `--nq-a-ink-secondary`, override days `--nq-a-warning-bg` with
`--nq-a-warning-ink` and a 1px warning border, today filled with the brand gradient in
white 800. Legend row beneath, 10.5px/600, one swatch per state.
- **Presence pattern** card: one row per child, name 13px/700 + rule 12px/600
(`Weeks 1 & 3 of cycle`, `Every day`).
- **Overrides** card: `Add` text action; rows with `calendar-x` / `calendar-check`,
title `<Child> away · <Mon D – D>`, meta reason, trailing `trash-2`.
- Cycle position is always computed from the anchor date. **Never** from ISO week numbers
or week parity (D-004).
## 5. History (1g)
- Header `History` / `Append-only event log · last 14 days` + bordered `CSV` button
(`download` glyph) that exports the filtered range.
- Stat row: `91%` On time · `128` Completed · `7` Missed, numerals 20px/800.
- Filter chips: `All events` / `Reversals` / `Missed`.
- Day-grouped sections (label 11px/700 uppercase) of event rows: 7px status dot
(green completed, `--nq-a-reversal` un-completed, `--nq-a-danger` missed) ·
title `<Quest> · <Child>` 12.5px/700 · meta 10.5px/600 giving event type, actor and
on-time flag (`completed · panel (Declan) · on time`, `uncompleted · joshua (admin)`,
`missed · nightly sweep`, `completed · panel (Declan) · late 22 min`) · time 11px/600.
- Footer strip (`--nq-a-page`, `lock` glyph): "Events are never edited or deleted. A
reversal is its own row." Rows are never editable in the UI (D-005).
## 6. Presence override editor (1i)
Pushed screen with a back chevron; title `Presence override` / sub "Beats the custody
pattern for these dates".
- Form card (roomy density, 18px padding, 16px gaps):
**Child** 3-up segmented, selected = gradient;
**Status for these dates** 2-up segmented (`Away` / `Home`), selected = `#18181D`;
**From** / **Through** date fields side by side, 13px padding, 14px/700 values;
**Reason (optional)** text field.
- **Consequence warning** — `--nq-a-warning-bg` card with `alert-circle`:
headline `This removes N upcoming tasks` 13.5px/700 `--nq-a-warning-ink`, body 12px/600
`--nq-a-warning-ink-2` naming the child, the date range, and stating "Completed days are
untouched." The count must be computed live from the materialiser before saving.
- Footer: `Cancel` (flex 1, white, bordered) + `Save override` (flex 2, gradient), 15px
vertical padding.
- Saving must never delete an instance that already has a completion event.
-82
View File
@@ -1,82 +0,0 @@
# NestQuest — design handoff
Approved UI design for the NestQuest Home Assistant integration, ready to implement.
Source of truth for visuals: the mockup file `NestQuest Mockups.dc.html` (9 screens,
option ids `1a`–`1j`). This folder is the written form of those screens.
## What is in here
| File | Contents |
|---|---|
| `PANEL-SPEC.md` | Kids' DAKOS wall panel: party board, quest log, confirm, quest complete |
| `ADMIN-SPEC.md` | Parent phone admin: today, definitions, schedule, history, overrides |
| `ENTITIES-AND-SERVICES.md` | Every screen mapped to the HA entities, services and events it needs |
| `IMPLEMENTATION-ORDER.md` | Build order, with the decisions that change existing code first |
| `tokens/nestquest-panel-tokens.css` | Panel card custom properties |
| `tokens/nestquest-admin-tokens.css` | Admin card custom properties |
## Screen index
| id | Screen | Surface | Spec |
|---|---|---|---|
| 1a | The Party (attract) | panel 1920×1080 | PANEL §2 |
| 1b | Quest Log | panel 1920×1080 | PANEL §3 |
| 1j | Complete confirm | panel 1920×1080 | PANEL §4 |
| 1c | Quest complete | panel 1920×1080 | PANEL §5 |
| 1d | Today (compact) | phone 390×844 | ADMIN §2 |
| 1e | Definitions + edit sheet | phone 390×844 | ADMIN §3 |
| 1f | Schedule + custody | phone 390×844 | ADMIN §4 |
| 1g | History + CSV | phone 390×844 | ADMIN §5 |
| 1h | Today (roomy) | phone 390×844 | ADMIN §2.4 |
| 1i | Presence override | phone 390×844 | ADMIN §6 |
## Design decisions taken during this handoff
These were open in CTXD and are now settled. Several contradict the current repo and
docs — read `IMPLEMENTATION-ORDER.md` §1 before writing code.
1. **`quest_*` naming throughout.** The domain model is renamed from `task_*` to
`quest_*` in DB, services, entities and UI. Migration note in IMPLEMENTATION-ORDER §1.1.
2. **Instance key becomes `(definition_id, child_id, due_date, window)`.** Required by
multi-assignee definitions and by twice-daily quests. Supersedes the documented
`(definition_id, due_date)`.
3. **A definition may have multiple assignees**, and materialises one instance per
assigned child per date per window. Each child completes their own.
4. **A definition may declare more than one window** (morning / afternoon / evening),
producing one instance per window per day.
5. **Missed quests vanish from the panel at rollover.** Kids never see a wall of failure;
the missed event is in the parent's history only.
6. **Three Lovelace cards**: `nestquest-party-board-card`, `nestquest-quest-log-card`,
`nestquest-admin-card`. Board and log live in separate Lovelace views; a crest tap
navigates.
7. **Completion needs a confirm step** (screen 1j) because children cannot undo.
8. **The quest log owns idle return**: 40 s of no touch navigates back to the board.
TouchHub Auto-Return must be configured longer than 40 s so the two do not fight.
9. **Actor on a panel completion** is `panel` plus the tapped child profile, per glossary.
10. **Weather is a configured entity** on the party board card (`weather_entity`), not
NestQuest data.
11. **Fonts are bundled** as local woff2 in the card's `www/` folder — the panel may be
offline. Nunito (400/600/700/800) and Cinzel + Cinzel Decorative.
## Vocabulary
The panel speaks tabletop; the admin speaks plainly. Both sit on the same `quest_*` model.
| Model / admin | Panel display string |
|---|---|
| quest instance | quest |
| child | adventurer |
| child's task list | Quest Log |
| household | The Party |
| all complete | Quest complete |
| absent | On travels |
## Open questions
- Empty and error states are unspecified by request (child with no quests, everyone away,
integration unconfigured, DB unreachable). Panel must not render a blank parchment —
decide before shipping the card.
- No gamification is in scope. XP, levels, streaks and rarity were explicitly deferred,
but the append-only event log already supports computing them later.
- Icon per quest definition is chosen by the parent from a Lucide subset; the subset is
not yet defined (`ADMIN-SPEC.md` §3.3).
-117
View File
@@ -1,117 +0,0 @@
# Screens → HA entities, services and events
Planned surface from CTXD CONTEXT.MD, adjusted for the design decisions in
`DESIGN-HANDOFF.md`. Names use the `quest_*` model.
## 1. Entities the cards read
| Entity | Type | Used by |
|---|---|---|
| `sensor.nestquest_<child>_quests_due_today` | int | 1a plate progress, 1b counts, 1d stats |
| `sensor.nestquest_<child>_quests_completed_today` | int | 1a, 1b, 1c, 1d, 1h |
| `sensor.nestquest_<child>_quests_remaining_today` | int | 1b remaining block, 1d |
| `sensor.nestquest_<child>_completion_pct_today` | float | progress bars |
| `sensor.nestquest_<child>_next_quest` | str + attrs | optional panel hint |
| `binary_sensor.nestquest_<child>_all_done` | bool | 1c trigger, 1d "all done" |
| `binary_sensor.nestquest_<child>_present_today` | bool | 1a away plate, 1d away row |
| `sensor.nestquest_household_quests_due_today` | int | 1b party line, 1c party stat |
| `sensor.nestquest_household_quests_completed_today` | int | same |
| `sensor.nestquest_cycle_day` | int | 1d sub-line, 1f header |
| *configured* `weather.<entity>` | HA core | panel dock |
Per-instance data is too granular for entity state. The cards read the day's instance list
from an attribute payload on the per-child sensor (or a websocket command —
implementer's call), shaped:
```json
{
"instances": [
{ "id": 4821, "definition_id": 12, "child_id": 3, "title": "Fill bird feeder",
"icon": "bird", "window": "afternoon", "due_time": "17:00",
"state": "open", "overdue": true, "completed_at": null, "on_time": null }
]
}
```
`state` ∈ `open` | `completed`. Missed instances are **omitted** from panel payloads
(decision 5) and included in admin payloads.
## 2. Services the cards call
| Service | Admin only | Called from |
|---|---|---|
| `nestquest.complete_quest` | no (panel allowed) | 1j confirm |
| `nestquest.uncomplete_quest` | **yes** | 1d `Undo` |
| `nestquest.create_quest_definition` | **yes** | 1e `New` |
| `nestquest.update_quest_definition` | **yes** | 1e sheet `Save changes` |
| `nestquest.set_quest_definition_active` | **yes** | 1e row action |
| `nestquest.set_presence_pattern` | **yes** | 1f presence card |
| `nestquest.create_presence_override` | **yes** | 1i `Save override` |
| `nestquest.delete_presence_override` | **yes** | 1f override row `trash-2` |
| `nestquest.export_history_csv` | **yes** | 1g `CSV` |
| `nestquest.manage_child` | **yes** | settings |
`complete_quest` payload from the panel:
```yaml
service: nestquest.complete_quest
data:
instance_id: 4821
actor: panel # kiosk user context
actor_child_id: 3 # the tapped profile (decision 9)
```
Every service resolves `call.context.user_id` against the admin allowlist and **fails
closed** with no user context (D-003). The panel's kiosk user is never allowlisted, so
`uncomplete_quest` is unreachable from the wall even though the card never renders the
control.
## 3. Events the cards listen for
| Event | Effect |
|---|---|
| `nestquest_quest_completed` | stamp the seal, re-sort the column, refresh counts |
| `nestquest_quest_uncompleted` | un-seal, restore the card (admin action, panel must reflect it) |
| `nestquest_quest_missed` | remove the instance from the panel; add a history row |
| `nestquest_child_day_complete` | show screen 1c |
All four come off the `DataUpdateCoordinator`; cards should also refresh on
`state_changed` for their sensors so they recover if an event is dropped.
## 4. Card configuration
```yaml
# kids' panel, view 1
type: custom:nestquest-party-board-card
weather_entity: weather.home # optional; dock falls back to date/time
quest_log_path: /nestquest/log # view the crest tap navigates to
child_order: [declan, jordyn, chloe]
# kids' panel, view 2
type: custom:nestquest-quest-log-card
board_path: /nestquest/board
idle_return_seconds: 40 # must be shorter than TouchHub Auto-Return
confirm_timeout_seconds: 15
complete_screen_seconds: 12
# parent phone
type: custom:nestquest-admin-card
density: compact # compact | roomy
default_tab: today
```
The quest log reads the selected child from the view's URL/`hass` path parameter so the
board → log hop is a plain Lovelace navigation (decision 6).
## 5. Data-shape consequences of the design
1. Instance key is `(definition_id, child_id, due_date, window)` — the schema's
`task_instances` unique index must widen.
2. `quest_definitions` needs an **assignees** join table, replacing the single
`child_id` column.
3. `quest_definitions` needs a **windows** set (morning/afternoon/evening) plus an
optional due time per window.
4. The materialiser emits one instance per (assignee × window × firing date), still
idempotent on the widened key, still only when the child is present.
5. `completion_events` gains `actor_child_id` so a panel completion records which
profile was tapped, while `actor` stays `panel`.
-80
View File
@@ -1,80 +0,0 @@
# Implementation order
Written for an agent working in `code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`.
Steps 1.x change existing code and must land first; the frontend is step 4 onward.
## 1. Model changes (breaking, pre-release)
### 1.1 Rename `task_*` → `quest_*`
Nothing is released (main/dev are at "first commit", version 0.1.0), so rename in place
rather than aliasing.
- `schema.py`: `task_definitions` → `quest_definitions`, `task_instances` →
`quest_instances`. Keep `completion_events`, `children`, `admin_users`,
`schedule_rules`, `presence_schedules`, `presence_overrides`.
- `db.py` / `store.py`: rename DAO methods and any SQL literals.
- `const.py`: no table names live there today; keep it that way.
- `tests/test_schema.py`, `tests/test_store.py`, `tests/test_db.py`: update expectations.
- **Migration note:** the versioned migration runner has no released version to upgrade
from. Bump the schema version, and make migration 1 create the `quest_*` tables
directly. If any dev machine already holds a `nestquest.db` with `task_*` tables, the
runner should detect the legacy names and `ALTER TABLE ... RENAME TO` before applying
the widened index — a dev-only path, worth ~20 lines and a test.
### 1.2 Widen the instance key
Unique index becomes `(definition_id, child_id, due_date, window)`. Materialisation stays
idempotent on that tuple. Add a test for the twice-daily case (one definition, two windows,
one child, one date → two rows) and the shared case (one definition, three assignees, one
window → three rows).
### 1.3 Multi-assignee definitions
Add `quest_definition_assignees(definition_id, child_id)`. Drop the single `child_id`
column on the definition. The "exactly one assigned child" rule in GLOSSARY.MD no longer
holds — CTXD has been updated.
### 1.4 Windows
Add `quest_definition_windows(definition_id, window, due_time)` with
`window ∈ ('morning','afternoon','evening')`. Window clock ranges are fixed for now:
morning ≤ 11:59, afternoon 12:00–17:00, evening 17:00–21:00. Store the ranges in
`const.py` so they are configurable later.
### 1.5 `actor_child_id` on completion events
Nullable int. Set for panel completions, null for admin ones. Still append-only.
## 2. Engines and materialisation
Unchanged in intent: pure recurrence engine, pure presence engine with anchor-date
arithmetic, daily materialiser over `horizon_days` at `day_rollover_time`. Only the
instance fan-out changes (assignees × windows).
## 3. Services, permissions, entities
Implement the service table in `ENTITIES-AND-SERVICES.md` §2 behind the permission
decorator, then the sensors and binary sensors in §1, then the four events in §3.
`complete_quest` is the only service a non-admin context may call.
## 4. Frontend groundwork
1. Create `custom_components/nestquest/www/`.
2. Bundle fonts as woff2: Nunito 400/600/700/800; Cinzel 600/700/900; Cinzel Decorative
700/900. Declare `@font-face` with `font-display: swap` in a shared
`nestquest-fonts.css`. No CDN — the panel may be offline.
3. Copy `design/tokens/nestquest-panel-tokens.css` and
`design/tokens/nestquest-admin-tokens.css` in as the cards' `:host` blocks.
4. Register the three cards with `customCards` and ship editors later; hand-written YAML
is fine for v1.
## 5. Cards, in this order
1. `nestquest-party-board-card` (screen 1a) — read-only, proves the entity payload.
2. `nestquest-quest-log-card` (1b) — read-only first, no completion.
3. Confirm dialog + `complete_quest` (1j) with optimistic seal and revert-on-failure.
4. Quest complete screen (1c) driven by `nestquest_child_day_complete`.
5. Idle return (40 s) and the documented TouchHub Auto-Return constraint.
6. `nestquest-admin-card`: Today (1d) → Definitions + sheet (1e) → Schedule (1f) →
History + CSV (1g) → Presence override (1i).
## 6. Before shipping
- `use_x_frame_options: false` is required for TouchHub embedding — document the
clickjacking trade-off next to the setting, per D-006.
- Verify no NestQuest control in the dock strip is tappable.
- Verify the kiosk HA user is absent from the admin allowlist, and that
`uncomplete_quest` denies it with a logged denial.
- Empty and error states are still unspecified (see DESIGN-HANDOFF "Open questions").
Do not ship a card that renders a blank parchment.
-170
View File
@@ -1,170 +0,0 @@
# Panel spec — kids' DAKOS wall panel
Screens `1a`, `1b`, `1j`, `1c` of the mockups. Two cards:
`nestquest-party-board-card` (§2) and `nestquest-quest-log-card` (§3–5).
## 1. Frame and constraints
- **Canvas** 1920×1080, landscape, fixed. No scrolling — content must fit. Every screen in
the mockups measures exactly 1920×1080 with no overflow; keep it that way.
- **Viewing distance** ~3 ft. **No text below 22px.** Meta lines are 22px/600 Nunito;
quest titles 30px/700 Cinzel; section heads 32px.
- **Contrast** every ink token is ≥4.5:1 on parchment. Do not introduce alpha-muted text.
- **Ground** `--nq-p-parchment` + `--nq-p-vignette`, CSS only, no image assets.
- **Double frame** inset 26px (`--nq-p-frame-outer`, radius 14px) and inset 36px
(`--nq-p-frame-inner`, radius 8px), both `pointer-events: none`.
- **Dock** bottom strip, 84px, inset 46px, radius 14px, `--nq-p-dock-bg`. Weather only.
**Nothing in the dock is tappable** (D-006).
- **No login.** Every action comes from the kiosk HA user; the kiosk user is never in the
admin allowlist.
## 2. The Party — attract state (1a)
Default view on the wall. Layout top to bottom:
1. **Wordmark row** at y=64. Rule · d20 hexagon · `NestQuest` (66px/900 display, brand
gradient via `background-clip: text`) · d20 · rule. d20 marks are 34px hexagons
(`clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)`) filled with
the brand gradient, numeral 15px/700 Cinzel.
2. **Kicker** `The Party · <weekday>, <Month D>` — 21px/600 Cinzel, tracking .30em, uppercase.
3. **Adventurer plates**, 3-up grid, 56px gap, page inset 110px, top y=262. One plate per
child returned by the party sensor, in configured order.
4. **Hint line** `Tap your crest to open your Quest Log`, 27px/600 Nunito, y≈930.
5. **Dock.**
### 2.1 Adventurer plate (present)
Radius 20px, `--nq-p-card-panel`, 2px `--nq-p-panel-border`, `--nq-p-panel-shadow`,
padding 44px 28px 40px, contents centred, 26px gap. **The whole plate is the tap target**
(≥188px wide, ≥600px tall — far above minimum).
- **Crest** 188×214, shield via `clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)`,
`--nq-p-crest`, inset ring `inset 0 0 0 4px rgba(255,255,255,.22)`, child's initial
82px/700 display in white.
- **Name** 48px/700 Cinzel, `--nq-p-ink`, tracking .02em.
- **Presence pill** radius 9999px, `#e8f4ea` on `rgba(22,120,60,.35)`, `check-circle` 22px
`#157a3c`, label `Home today` 19px/600 Cinzel uppercase `#146b36`.
- **Progress line** `N of M quests claimed` 26px/700 Cinzel `--nq-p-ink-secondary`.
- **Progress bar** full width, 16px, radius 9999px, track `rgba(92,62,26,.18)` with 1px
border, fill `--nq-brand-gradient-h`.
### 2.2 Adventurer plate (away)
Same geometry, muted: `linear-gradient(#f2ecdd, #e6dcc6)`, **2px dashed** border, no lift
shadow. Crest uses `--nq-p-crest-away`; initial stays **full-opacity white** (never alpha).
Name in `--nq-p-ink-away`. Pill: `tent` icon, label `On travels`, neutral fill. Progress
line reads `Returns <weekday>, <Mon D>`; empty progress track, no fill. **Not tappable** —
the plate must not navigate, since the child has no instances.
### 2.3 Dock
`cloud-sun` 40px · temperature 38px/800 Nunito `--nq-p-dock-ink` · 1px divider ·
condition + high/low 26px/600 `--nq-p-dock-ink-secondary` · divider · one short forecast
phrase. All read from the configured `weather_entity`; if unset, render the dock with date
and time only rather than removing it (the strip is part of the frame).
## 3. Quest Log (1b)
Reached by tapping a present crest. Header at y=56, page inset 62px:
- Small crest 96×110 (initial 44px/700 display).
- Title `<Name>'s Quest Log` 56px/700 display.
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
- **Remaining block** right-aligned: radius 14px panel, d20 mark 44px with the remaining
count, `N quests left` 28px/700 Cinzel, and a party line
`Party progress · X of Y today` 22px/600 Nunito.
### 3.1 Window columns
Three equal columns, 34px gap, top y=236, one per window — **Morning** (until 11:59 AM),
**Afternoon** (12:00–5:00 PM), **Evening** (5:00–9:00 PM). Column head: icon
(`sunrise` / `sun` / `moon`) 34px, name 32px/700 Cinzel, sub-label 21px/600 Cinzel
uppercase with the window's clock range, right-aligned `done/total` 26px/700 Cinzel,
14px bottom padding, `--nq-p-rule` underline.
Cards stack at `--nq-p-quest-gap` (24px). Order within a column: **incomplete first,
sealed below**. A window with no quests renders its head and nothing else — do not
collapse the column, the three-column rhythm is the layout.
### 3.2 Quest card — open
`--nq-p-quest-min-height` 116px, radius 12px, `--nq-p-card-open`, 1px
`--nq-p-card-border`, `--nq-p-card-shadow`, padding 22px, 22px gap, row layout:
1. **Icon tile** 64×64, radius 10px, `--nq-p-icon-tile`, Lucide glyph 34px white.
2. **Text** title 30px/700 Cinzel `--nq-p-ink`; meta 22px/600 Nunito
`--nq-p-ink-secondary` reading `Due by <h:mm A>`. If past due:
`Overdue · due <h:mm A>` in 22px/700 Cinzel uppercase, tracking .10em,
`--nq-p-ink-late`.
3. **Complete button** `--nq-p-button-height` 72px × min 150px, radius 10px,
`--nq-brand-gradient`, `check` 30px + `Complete` 24px/700 Cinzel white,
`box-shadow: 0 3px 0 rgba(40,20,60,.35)`.
Both the card body and the button open the confirm dialog (§4). Press feedback:
`scale(.98)`, `--nq-dur-micro`.
### 3.3 Quest card — sealed
`--nq-p-card-done`, `opacity: .82`, no shadow, **not tappable**. Icon tile
`--nq-p-icon-tile-done` with the glyph in `--nq-p-ink-secondary`. Title 30px/700 Cinzel
`--nq-p-ink-muted` with `text-decoration: line-through`. Meta reads
`Claimed <h:mm A>` 22px/600 Nunito `--nq-p-ink-secondary`.
**Wax seal** `--nq-p-seal-size` 78px circle, `--nq-p-seal`, `--nq-p-seal-shadow`,
`check` 38px in `rgba(255,235,235,.95)`, absolutely positioned `right: 16px; top: -12px`,
rotated a few degrees — **vary the rotation per card** (−9°, +6°, −4° in the mockup) so the
board looks stamped by hand, not templated. Entrance: fade + `scale(1.15 → 1)`,
`--nq-dur-base`, `--nq-ease-out`.
### 3.4 Party roll-up
An optional dashed panel in the afternoon column: label `Party roll-up` 18px/600 Cinzel
uppercase, body 23px/600 Nunito `--nq-p-ink-secondary` — one or two sentences on the other
children ("Jordyn has finished her log. Chloe is on travels until Sunday."). Read-only.
### 3.5 Idle return
40 s with no touch → navigate back to the party board view. Reset on any pointer or touch
event. The card owns this; TouchHub Auto-Return must be set longer than 40 s.
## 4. Complete confirm (1j)
Modal over the dimmed log — scrim `rgba(30,20,10,.62)` over the live board.
Dialog 1000px wide at `top: 196px; left: 460px`, radius 20px, parchment panel with 3px
`rgba(92,62,26,.5)`, `box-shadow: 0 26px 60px rgba(20,10,0,.5)`, padding 64px 68px 56px.
- Header row: 104px icon tile (radius 12px, brand gradient, glyph 56px) · window kicker
21px/600 Cinzel uppercase tracking .24em · quest title 58px/700 display.
- Body 32px/600 Nunito `#3f2f1c`: `Mark this quest complete? Once the seal is set, only a
parent can undo it.`
- Buttons, 24px gap, both `--nq-p-confirm-button` 108px tall:
**Not yet** flex 1, `rgba(92,62,26,.1)` with 2px border, `x` 34px + 30px/700 Cinzel;
**Complete** flex 2, brand gradient, `check` 38px + 32px/700 Cinzel white.
- Dialog enters with fade + `scale(.96 → 1)`, `--nq-dur-modal`, `--nq-ease-out`.
- Dismiss: **Not yet**, scrim tap, or 15 s of no input.
On confirm, call the completion service optimistically, stamp the seal, and re-sort the
column. On service failure, revert the card and surface a single-line parchment toast.
## 5. Quest complete (1c)
Shown when the last instance for a child's day is completed; auto-returns to the board
after 12 s (countdown line `Returning to The Party in N seconds`, 25px/600 Nunito).
- Large seal 168px at y=96 (`check` 86px), rotated −6°, `0 10px 24px rgba(60,10,20,.42)`.
- `Quest complete` 86px/900 display, brand-gradient text fill.
- Sub 34px/600 Nunito `--nq-p-ink-secondary`, max-width 1000px, centred, `text-wrap: pretty`:
names the child, the date, and how the day went.
- **Stat blocks** 4-up at y=540, inset 200px, 26px gap: radius 16px parchment panels,
numeral 58px/900 Cinzel, label 17px/600 Cinzel uppercase tracking .14em —
`Quests claimed`, `On time`, `Late`, `The Party` (household X/Y).
- Dock switches to the evening variant (`cloud-moon`).
## 6. Accessibility and input
- Minimum tap target on this surface is the quest card itself (116px) and the 72px button;
nothing interactive is smaller than 72px.
- 24px between adjacent tap targets so a stray finger cannot claim the wrong quest.
- No hover states — this is a touch-only surface. Provide `:active` feedback only.
- No animation loops. Entrances only, per the CubeCraft motion rules.
-76
View File
@@ -1,76 +0,0 @@
/* ============================================================
NestQuest — Parent admin card tokens
Surface: phone, 390x844 reference. Plain CubeCraft styling,
no tabletop treatment, plain domain words in the UI.
Scope on the card host: nestquest-admin-card.
Derived from the approved mockups (admin screens 1d-1i).
Two densities were built; COMPACT is the default. Flip
--nq-a-density-* to the roomy values for the alternative.
============================================================ */
:host {
/* ── Brand + neutrals (CubeCraft tokens, verbatim) ─────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-a-page: #F4F4F6;
--nq-a-surface: #FFFFFF;
--nq-a-surface-subtle: #FAFAFA;
--nq-a-selected: #EBEBF8; /* row being edited */
--nq-a-border: #E4E4EA;
--nq-a-divider: #EDEDF1;
--nq-a-ink: #18181D;
--nq-a-ink-secondary: #52525E;
--nq-a-ink-tertiary: #70707E; /* inactive rows, unselected nav icons */
--nq-a-success: #15803D;
--nq-a-danger: #DC2626;
--nq-a-danger-strong: #B91C1C;
--nq-a-danger-bg: #FEE2E2;
--nq-a-warning-ink: #92400E;
--nq-a-warning-ink-2: #78350F;
--nq-a-warning-bg: #FEF3C7;
--nq-a-warning-border: rgba(217,119,6,.35);
--nq-a-info-bg: #DBEAFE;
--nq-a-info-ink: #1E3A8A;
--nq-a-info-icon: #1D4ED8;
--nq-a-reversal: #7D28A0; /* un-complete events in history */
/* ── Type ─────────────────────────────────────────────── */
--nq-a-font: Nunito, system-ui, sans-serif;
--nq-a-size-screen: 24px; /* 800, screen title */
--nq-a-size-hero: 32px; /* 800, roomy "8 of 12 done" */
--nq-a-size-card-title: 19px; /* 800, roomy child card */
--nq-a-size-stat: 22px; /* 800, compact stat number */
--nq-a-size-row: 13.5px;/* 700, list row title */
--nq-a-size-meta: 11px; /* 600, row meta */
--nq-a-size-label: 10px; /* 700, uppercase field label */
--nq-a-track-label: .10em;
/* ── Density: COMPACT (default) ───────────────────────── */
--nq-a-density-page-pad: 14px 16px 92px; /* bottom clears the 70px tab bar */
--nq-a-density-gap: 10px;
--nq-a-density-card-pad: 13px;
--nq-a-density-row-pad: 11px 13px;
--nq-a-density-radius: 8px;
--nq-a-density-shadow: none;
/* Density: ROOMY — page-pad 18px 20px 92px; gap 16px;
card-pad 18px; radius 12px; shadow 0 4px 12px rgba(0,0,0,.06) */
/* ── Metrics ──────────────────────────────────────────── */
--nq-a-tabbar-height: 70px; /* + 12px safe-area padding */
--nq-a-tap-min: 44px; /* every row/control clears this */
--nq-a-radius-pill: 9999px;
--nq-a-radius-sheet: 20px 20px 0 0;
--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);
--nq-a-progress-height: 6px; /* 8px roomy, 10px hero */
/* ── Motion ───────────────────────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
-94
View File
@@ -1,94 +0,0 @@
/* ============================================================
NestQuest — Kids' panel card tokens
Surface: DAKboard DAKOS wall panel, 1920x1080 landscape,
viewed from ~3 ft, no login, read/complete only.
Scope these on the card host: nestquest-quest-log-card,
nestquest-party-board-card.
Derived from the approved mockups (panel screens 1a, 1b, 1c, 1j).
============================================================ */
:host {
/* ── Parchment ground ─────────────────────────────────── */
--nq-p-parchment-top: #f0e4c7;
--nq-p-parchment-bottom: #ddcca3;
--nq-p-parchment: radial-gradient(ellipse at 25% 10%, rgba(255,255,255,.5), transparent 55%),
radial-gradient(ellipse at 85% 90%, rgba(120,86,44,.28), transparent 60%),
repeating-linear-gradient(93deg, rgba(150,115,70,.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(2deg, rgba(150,115,70,.04) 0 3px, transparent 3px 7px),
linear-gradient(var(--nq-p-parchment-top), var(--nq-p-parchment-bottom));
--nq-p-vignette: inset 0 0 200px rgba(80,52,20,.35);
/* Card surfaces sitting on parchment */
--nq-p-card-open: linear-gradient(#fdf7e6, #f2e7c9); /* incomplete quest */
--nq-p-card-done: linear-gradient(#f0e8d3, #e6dcc2); /* sealed quest */
--nq-p-card-panel: linear-gradient(#fcf6e6, #f1e6ca); /* adventurer plate, stat block */
--nq-p-card-border: rgba(120,88,48,.38);
--nq-p-panel-border: rgba(92,62,26,.40);
--nq-p-card-shadow: 0 3px 0 rgba(120,88,48,.20), inset 0 1px 0 rgba(255,255,255,.70);
--nq-p-panel-shadow: 0 6px 0 rgba(92,62,26,.18), inset 0 2px 0 rgba(255,255,255,.70);
--nq-p-frame-outer: 2px solid rgba(92,62,26,.45);
--nq-p-frame-inner: 1px solid rgba(92,62,26,.28);
--nq-p-rule: 2px solid rgba(92,62,26,.35);
/* ── Ink (all values >= 4.5:1 on parchment) ───────────── */
--nq-p-ink: #2b1f14; /* titles, quest names */
--nq-p-ink-secondary: #5c452a; /* meta, window labels, counts */
--nq-p-ink-muted: #6f6455; /* sealed quest titles only */
--nq-p-ink-away: #4d4433; /* away adventurer name */
--nq-p-ink-late: #8f1526; /* overdue line */
/* ── Brand (CubeCraft, toned down: accents only) ──────── */
--nq-brand-blue: #3B3AB8;
--nq-brand-purple: #A035CC;
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
--nq-p-icon-tile: var(--nq-brand-gradient); /* open quest icon tile */
--nq-p-icon-tile-done: rgba(92,62,26,.16);
--nq-p-crest: var(--nq-brand-gradient);
--nq-p-crest-away: linear-gradient(135deg, #6b6b7a, #7a7286);
/* ── Wax seal (completed marker) ──────────────────────── */
--nq-p-seal: radial-gradient(circle at 35% 30%, #a8283a, #6d1322);
--nq-p-seal-shadow: 0 4px 10px rgba(60,10,20,.40), inset 0 0 0 4px rgba(255,255,255,.14);
--nq-p-seal-size: 78px; /* 168px on the quest-complete screen */
/* ── Dock (bottom strip, non-interactive) ─────────────── */
--nq-p-dock-bg: rgba(43,31,20,.90);
--nq-p-dock-ink: #f7efdb;
--nq-p-dock-ink-secondary: #d8c9a6;
--nq-p-dock-divider: rgba(233,220,189,.30);
--nq-p-dock-height: 84px;
/* ── Type ─────────────────────────────────────────────── */
--nq-p-font-display: 'Cinzel Decorative', Cinzel, serif; /* NestQuest, names, Quest complete */
--nq-p-font-heading: Cinzel, serif; /* section heads, quest titles, numerals */
--nq-p-font-body: Nunito, system-ui, sans-serif; /* meta lines, dock */
--nq-p-size-hero: 86px; /* 900 weight, "Quest complete" */
--nq-p-size-wordmark: 66px; /* 900 weight */
--nq-p-size-title: 56px; /* 700, "Declan's Quest Log" */
--nq-p-size-name: 48px; /* 700, adventurer name */
--nq-p-size-section: 32px; /* 700, Morning / Afternoon / Evening */
--nq-p-size-quest: 30px; /* 700, quest title */
--nq-p-size-body: 22px; /* 600 Nunito, meta — PANEL MINIMUM */
--nq-p-size-label: 21px; /* 600 Cinzel, uppercase, tracked */
--nq-p-track-label: .14em;
--nq-p-track-kicker: .30em;
/* ── Metrics: touch ───────────────────────────────────── */
--nq-p-quest-min-height: 116px; /* whole card is the tap target */
--nq-p-quest-gap: 24px; /* separation between tap targets */
--nq-p-button-height: 72px; /* "Complete" button on a quest card */
--nq-p-button-min-width: 150px;
--nq-p-confirm-button: 108px; /* confirm dialog buttons */
--nq-p-radius-card: 12px;
--nq-p-radius-panel: 20px;
--nq-p-radius-pill: 9999px;
--nq-p-page-inset: 62px;
--nq-p-frame-inset: 26px;
/* ── Motion (CubeCraft easing) ────────────────────────── */
--nq-ease-out: cubic-bezier(0, 0, .2, 1);
--nq-dur-micro: 120ms;
--nq-dur-base: 200ms;
--nq-dur-modal: 350ms;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "nestquest"
version = "0.3.0"
version = "0.1.0"
description = "NestQuest Home Assistant Integration"
readme = "README.md"
requires-python = ">=3.11"
-14
View File
@@ -41,7 +41,6 @@ _HA_MODULES = (
"homeassistant.core",
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
)
def _find_spec(name: str):
@@ -78,7 +77,6 @@ for _parent, _child in (
("homeassistant", "config_entries"),
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -160,7 +158,6 @@ def _callback_decorator(fn):
_config_entries_mock = _ha_mock("homeassistant.config_entries")
_core_mock = _ha_mock("homeassistant.core")
_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow")
_exceptions_mock = _ha_mock("homeassistant.exceptions")
_config_entries_mock.ConfigFlow = _ConfigFlowBase
_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase
@@ -171,17 +168,6 @@ _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
_data_entry_flow_mock.FlowResult = dict
class ConfigEntryNotReady(Exception):
"""Stand-in mirroring homeassistant.exceptions.ConfigEntryNotReady.
HA retries setup when this is raised; the tests assert the corrupt
database path raises it instead of crashing with an unrelated error.
"""
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
# ---------------------------------------------------------------------------
# Shared helpers for the behavior tests.
# ---------------------------------------------------------------------------
-30
View File
@@ -73,36 +73,6 @@ def test_panel_idle_timeout_config() -> None:
assert const.DEFAULT_PANEL_IDLE_TIMEOUT > 0
def test_quest_windows_canonical_order() -> None:
"""QUEST_WINDOWS is exactly the three spellings in canonical order."""
assert const.QUEST_WINDOWS == ("morning", "afternoon", "evening")
assert const.WINDOW_MORNING == "morning"
assert const.WINDOW_AFTERNOON == "afternoon"
assert const.WINDOW_EVENING == "evening"
# No duplicates, every name covered by its individual constant.
assert len(set(const.QUEST_WINDOWS)) == 3
def test_window_clock_ranges_cover_every_window() -> None:
"""Each window has a strict HH:MM (start, end) range.
The ranges follow the documented boundaries (D-008): morning ends
11:59, afternoon spans 12:00-17:00, evening 17:00-21:00, and each
window's end equals the next window's start (the boundary belongs
to the later window at classification time).
"""
assert const.WINDOW_CLOCK_RANGES == {
"morning": ("00:00", "11:59"),
"afternoon": ("12:00", "17:00"),
"evening": ("17:00", "21:00"),
}
for window in const.QUEST_WINDOWS:
start, end = const.WINDOW_CLOCK_RANGES[window]
for value in (start, end):
parsed = datetime.strptime(value, "%H:%M").time()
assert parsed.strftime("%H:%M") == value
def test_no_hardcoded_domain_or_db_filename_in_modules() -> None:
"""Ensure no module other than const.py hardcodes DOMAIN string or SQLITE_DB_FILENAME."""
pkg_dir = Path(const.__file__).parent
+1 -323
View File
@@ -481,22 +481,12 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
"custom_components/nestquest/dao_rules.py", # validates child
# activeness on assignment (FK to children, done-condition
# 'validates that the assigned child is active')
"custom_components/nestquest/dao_presence.py", # validates the
# schedule/override child exists before writing (same FK shape
# as dao_rules: one existence SELECT per write)
"custom_components/nestquest/dao_instances.py", # validates the
# instance/event child exists before writing (same FK shape)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_children.py", # this file, scanned separately
"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+"
@@ -504,329 +494,18 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None:
re.IGNORECASE,
)
offenders: list[str] = []
# test_dao_matrix.py is exempted for exactly its two sanctioned
# raw constraint-violation probes (NOT NULL on children, PRIMARY
# KEY on admin_users) — the Feature-02 closing suite must prove
# the schema surfaces violations to the DAO layer; its guard below
# covers everything else in that file.
probe_names = {
"test_matrix_children_crud_and_constraints",
"test_matrix_admin_users_crud_and_constraints",
}
for root in scan_roots:
for py in sorted(root.rglob("*.py")):
relative = py.relative_to(repo_root).as_posix()
if relative in allowed:
continue
text = py.read_text()
if relative == "tests/test_dao_matrix.py":
text = _strip_function_spans(text, probe_names)
if sql_pattern.search(text):
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching children/admin_users leaked into: {offenders}"
)
def _strip_function_spans(text: str, names: set[str]) -> str:
"""Return ``text`` minus the source spans of the named functions."""
import ast
lines = text.splitlines(keepends=True)
excluded: set[int] = set()
tree = ast.parse(text)
for node in ast.walk(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in names
):
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
return "".join(
line
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
(a) they contain NO other raw SQL naming children/admin_users, and
(b) every raw table insert among them is an expression nested inside
a ``pytest.raises`` context whose matcher mentions IntegrityError.
Any additional raw write elsewhere in the file fails this guard.
"""
import ast
import re
from pathlib import Path
matrix = Path(__file__).parent / "test_dao_matrix.py"
text = matrix.read_text()
stripped = _strip_function_spans(
text,
{
"test_matrix_children_crud_and_constraints",
"test_matrix_admin_users_crud_and_constraints",
},
)
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_dao_matrix.py raw children/admin_users SQL is limited to "
"the two sanctioned constraint probes"
)
tree = ast.parse(text)
probe_tables = {
"test_matrix_children_crud_and_constraints": ("children", "INSERT"),
"test_matrix_admin_users_crud_and_constraints": (
"admin_users",
"INSERT",
),
}
any_table = re.compile(
r"\b(children|admin_users)\b", re.IGNORECASE
)
allowed_stmt = re.compile(
r"^(INSERT|SELECT)", re.IGNORECASE
)
#: The sanctioned probe per function: exactly one raw statement
#: INSERTing that table, inside a pytest.raises whose matcher is
#: IntegrityError (checked as an AST Name/Attribute chain).
probes_per_function: dict[str, int] = {name: 0 for name in probe_tables}
for node in ast.walk(tree):
if (
not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
or node.name not in probe_tables
):
continue
table, expected_verb = probe_tables[node.name]
def _unquote(rendered: str) -> str:
rendered = rendered.strip()
if len(rendered) >= 2 and rendered[0] == rendered[-1] and (
rendered[0] in "'\""
):
return rendered[1:-1]
return rendered
# Collect EVERY database SQL entry point (execute, fetch_one,
# fetch_all, execute_many) wherever it appears in the awaited
# expression tree: a bypass through fetch_one/execute_many OR a
# call wrapped in another awaitable (asyncio.gather, a helper
# coroutine, a Task) must be caught the same as a bare await.
raw_statements: list[tuple[ast.AST, str]] = []
for sub in ast.walk(node):
if (
isinstance(sub, ast.Call)
and isinstance(sub.value if False else sub.func, ast.Attribute)
and sub.func.attr
in {"execute", "fetch_one", "fetch_all", "execute_many"}
):
sql_arg = sub.args[0] if sub.args else None
rendered = ast.unparse(sql_arg) if sql_arg else ""
raw_statements.append((sub, _unquote(rendered)))
def _raises_blocks():
"""Yield (block, is_pytest_integrity) for pytest.raises blocks.
Structurally exact: the call func must resolve to
pytest.raises — Attribute(pytest, raises) or a bare Name
whose id is bound to pytest via the file's own import
section (validated below). The matcher argument must be an
AST chain whose final segment is exactly IntegrityError (so
NotIntegrityError and fake.IntegrityError fail).
"""
# Names bound to pytest's raises: parse the FILE's actual
# Import nodes in TWO passes so order cannot matter — pass
# one collects every binding pytest's raises gets (whatever
# alias), pass two rejects ANY competing binding of those
# names (or of 'pytest' itself) from a non-pytest import,
# wherever it appears. A spoof imported BEFORE the genuine
# binding therefore still fails.
pytest_raises_aliases: set[str] = set()
for file_node in ast.walk(tree):
if (
isinstance(file_node, ast.ImportFrom)
and file_node.module == "pytest"
):
for alias in file_node.names:
if alias.name == "raises":
pytest_raises_aliases.add(
alias.asname or alias.name
)
raises_bindings: set[str] = set(pytest_raises_aliases)
for file_node in ast.walk(tree):
if isinstance(file_node, ast.ImportFrom):
if file_node.module == "pytest":
continue
for alias in file_node.names:
bound = alias.asname or alias.name
if bound in raises_bindings | {"pytest"}:
raise AssertionError(
f"shadowed import of {bound!r} from "
f"{file_node.module!r} defeats the "
"pytest.raises probe validation"
)
elif isinstance(file_node, ast.Import):
# import fake_module as pytest (or as any raises
# alias) fails closed. A plain `import pytest`
# (alias.name == 'pytest', no asname) is the genuine
# module binding and is allowed.
for alias in file_node.names:
bound = alias.asname or alias.name
genuine_pytest = (
alias.name == "pytest" and alias.asname is None
)
if bound in raises_bindings | {"pytest"} and (
not genuine_pytest
):
raise AssertionError(
f"import shadowing {bound!r} defeats the "
"pytest.raises probe validation"
)
for sub in ast.walk(node):
if not (
isinstance(sub, ast.With)
and sub.items
and isinstance(sub.items[0].context_expr, ast.Call)
):
continue
call = sub.items[0].context_expr
func = call.func
if isinstance(func, ast.Attribute):
if (
func.attr != "raises"
or not isinstance(func.value, ast.Name)
or func.value.id != "pytest"
):
continue
elif isinstance(func, ast.Name):
if func.id not in raises_bindings:
continue
else:
continue
matcher_ok = False
if call.args:
matcher = call.args[0]
matcher_ok = _is_integrity_error_node(matcher)
yield sub, matcher_ok
def _is_integrity_error_node(matcher) -> bool:
"""True for an AST chain of Name/Attribute ending exactly at
IntegrityError: every Attribute base must itself be a Name
or Attribute (a Call base like __import__('x') fails)."""
node = matcher
while isinstance(node, ast.Attribute):
node = node.value
if not isinstance(node, ast.Name):
return False
final = matcher
while isinstance(final, ast.Attribute):
final = final.attr
return (
isinstance(final, str)
and ast.unparse(matcher).split(".")[-1] == "IntegrityError"
)
# INSERT OR variant / quoted / qualified / schema-qualified
# names: normalize the SQL so ANY spelling of an insert into
# the table counts as a table insert and requires the raises
# block. Strip quotes/brackets/backticks from identifiers and
# drop any schema qualifier, whatever it is.
def _is_insert_into_table(rendered: str, table: str) -> bool:
insert_match = re.search(
r"\bINSERT(\s+OR\s+\w+)?\s+INTO\s+(.+)",
rendered,
flags=re.IGNORECASE | re.DOTALL,
)
if not insert_match:
return False
target = insert_match.group(2)
# Tokenize the target's leading identifier: strip quotes
# and brackets, take the LAST dot-separated segment,
# casefolded so 'CHILDREN' cannot bypass the lowercase
# table name the scan looks for.
identifier = re.split(r"[\s(]", target, maxsplit=1)[0]
segments = re.split(r"\.", identifier)
last = segments[-1].strip("`'\"[]")
return last.casefold() == table.casefold()
# In a sanctioned function, EVERY raw statement naming either
# table must be exactly that function's one expected-table
# constraint insert — an admin_users insert inside the children
# function (or vice versa) is a bypass attempt and fails here,
# as does any second insert of the expected table.
for stmt_node, rendered in raw_statements:
if not any_table.search(rendered):
continue
assert _is_insert_into_table(rendered, table), (
f"{node.name}: raw statement naming the other table is "
f"forbidden; only the {table} probe is sanctioned: "
f"{rendered[:80]!r}"
)
# This is the sanctioned probe: require pytest.raises
# with an exact IntegrityError matcher around it.
inside = any(
block.lineno <= stmt_node.lineno
and stmt_node.end_lineno <= block.end_lineno
and matcher_ok
for block, matcher_ok in _raises_blocks()
)
assert inside, (
f"the raw {table} insert in {node.name} must be nested "
"in a pytest.raises(...IntegrityError...) block "
"(expected-to-fail constraint probe, not a write)"
)
probes_per_function[node.name] += 1
# UPDATE/DELETE naming the tables anywhere in a sanctioned
# function is forbidden outright.
for _, rendered in raw_statements:
if any_table.search(rendered) and re.match(
r"(UPDATE|DELETE)", rendered.strip(), re.IGNORECASE
):
raise AssertionError(
f"{node.name} must not hold UPDATE/DELETE SQL for "
f"children/admin_users: {rendered[:80]!r}"
)
assert all(
count == 1 for count in probes_per_function.values()
) and len(probes_per_function) == 2, (
"each sanctioned function must hold exactly one raw constraint "
f"probe; found {probes_per_function}"
)
def test_dao_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. The two guard functions in this file necessarily mention
@@ -849,7 +528,6 @@ def test_dao_test_file_uses_dao_not_raw_table_sql() -> None:
guard_names = {
"test_children_and_admin_sql_lives_only_in_dao_module",
"test_dao_test_file_uses_dao_not_raw_table_sql",
"test_dao_matrix_test_file_raw_probes_are_only_constraint_probes",
}
excluded: set[int] = set()
for node in ast.walk(tree):
File diff suppressed because it is too large Load Diff
-440
View File
@@ -1,440 +0,0 @@
"""Cross-table DAO unit suite: CRUD and constraint coverage for all tables.
The per-DAO test modules (test_dao_children, test_dao_rules,
test_dao_presence, test_dao_instances) cover each table's operations in
depth; this module is the FEATURE-02 closing sweep: a single systematic
matrix proving that for EVERY table, the DAO layer supports insert,
read, update and surfaces constraint violations — plus the instance
upsert idempotency and the completion_events no-mutation guarantee —
all against a temporary database file from a clean checkout.
No raw SQL touches the DAO-owned tables here except the sanctioned
COUNT(*) probes (each guarded by the per-DAO leak guards); everything
else goes through the typed DAO methods.
"""
from __future__ import annotations
import asyncio
import datetime
import sqlite3
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest.dao_children import (
AdminUsersDao,
ChildrenDao,
)
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
)
from custom_components.nestquest.dao_rules import (
ScheduleRulesDao,
QuestDefinitionsDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
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 _future_day(offset: int) -> str:
return (
datetime.date.today() + datetime.timedelta(days=offset)
).isoformat()
def _stamp() -> str:
return f"{datetime.date.today().isoformat()}T08:00:00+00:00"
D1 = _future_day(1)
D2 = _future_day(2)
D20 = _future_day(20)
#: Every table the v1 schema owns, with the DAO that governs it.
ALL_TABLES = (
"children",
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_overrides",
"quest_instances",
"completion_events",
)
class World:
"""All DAOs over one migrated temporary database."""
def __init__(self, database: NestQuestDatabase):
self.database = database
self.children = ChildrenDao(database)
self.admins = AdminUsersDao(database)
self.rules = ScheduleRulesDao(database)
self.definitions = QuestDefinitionsDao(database)
self.schedules = PresenceSchedulesDao(database)
self.overrides = PresenceOverridesDao(database)
self.instances = QuestInstancesDao(database)
self.events = CompletionEventsDao(database)
async def seed(self) -> None:
"""Populate one valid fixture chain through the DAO layer."""
self.child = await self.children.create("Ada", _stamp())
self.child2 = await self.children.create("Bo", _stamp())
assert await self.admins.add("user-1", _stamp()) is True
self.rule = await self.rules.create("daily", D1)
self.definition = await self.definitions.create(
"Brush teeth",
self.rule.id,
_stamp(),
assignee_child_ids=[self.child.id],
)
self.schedule = await self.schedules.upsert_by_child(
self.child.id, 2, D1, "0,2,4|1,3"
)
self.override = await self.overrides.create(
self.child.id, D1, D2, True, note="test"
)
self.instance = await self.instances.upsert(
self.definition.id, self.child.id, D1, _stamp()
)
async def _world(path) -> World:
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
world = World(database)
await world.seed()
return world
def _with_world(name):
def _run_test(body):
async def _main():
world = await _world(name)
try:
return await body(world)
finally:
await world.database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# The matrix: every table gets insert / read / update / constraint check
# ---------------------------------------------------------------------------
def test_matrix_children_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.children.get(w.child.id)
assert fetched is not None and fetched.display_name == "Ada"
# update
await w.children.update(w.child.id, display_name="Ada L.")
assert (await w.children.get(w.child.id)).display_name == "Ada L."
# constraint: NOT NULL display_name
with pytest.raises(sqlite3.IntegrityError):
await w.database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES (NULL, ?)",
(_stamp(),),
)
return True
assert _with_world(tmp_path / "m-children.db")(_body) is True
def test_matrix_admin_users_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert + read
assert await w.admins.exists("user-1") is True
records = await w.admins.list()
assert len(records) == 1
# update path: idempotent add is the allowlist's update semantics
assert await w.admins.add("user-1", _stamp()) is False
# constraint: ha_user_id PRIMARY KEY
with pytest.raises(sqlite3.IntegrityError):
await w.database.execute(
"INSERT INTO admin_users (ha_user_id, added_at) "
"VALUES (?, ?)",
("user-1", _stamp()),
)
# remove
assert await w.admins.remove("user-1") is True
assert await w.admins.exists("user-1") is False
return True
assert _with_world(tmp_path / "m-admins.db")(_body) is True
def test_matrix_schedule_rules_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.rules.get(w.rule.id)
assert fetched.rule_type == "daily"
# update
await w.rules.update(w.rule.id, interval=3)
assert (await w.rules.get(w.rule.id)).interval == 3
# constraint: rule_type CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.rules.create("sometimes", D1)
# delete-if-unreferenced: referenced -> rejected
assert await w.rules.delete_if_unreferenced(w.rule.id) is False
return True
assert _with_world(tmp_path / "m-rules.db")(_body) is True
def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.definitions.get(w.definition.id)
assert fetched.title == "Brush teeth"
assert [c.id for c in await w.definitions.list_assignees(
w.definition.id
)] == [w.child.id]
# update
await w.definitions.update(w.definition.id, title="Brush TEETH")
assert (await w.definitions.get(w.definition.id)).title == (
"Brush TEETH"
)
# constraint: FK to a nonexistent child rejected with ValueError
with pytest.raises(ValueError, match="does not exist"):
await w.definitions.create(
"X", w.rule.id, _stamp(), assignee_child_ids=[999]
)
# constraint: inactive child rejected on assignment
await w.children.set_active(w.child2.id, False)
with pytest.raises(ValueError, match="inactive"):
await w.definitions.add_assignee(w.definition.id, w.child2.id)
# assignee removal + re-adding the active child
assert await w.definitions.remove_assignee(
w.definition.id, w.child.id
) is True
assert await w.definitions.remove_assignee(
w.definition.id, w.child.id
) is False
await w.definitions.add_assignee(w.definition.id, w.child.id)
# set_active: deactivate, never delete
assert await w.definitions.set_active(w.definition.id, False) == 1
assert (await w.definitions.get(w.definition.id)).is_active is False
return True
assert _with_world(tmp_path / "m-definitions.db")(_body) is True
def test_matrix_presence_schedules_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.schedules.get_by_child(w.child.id)
assert fetched.pattern == "0,2,4|1,3"
# update path: upsert replaces in place (same row id)
replaced = await w.schedules.upsert_by_child(
w.child.id, 2, D2, "1,3|0,2,4"
)
assert replaced.id == fetched.id
assert (await w.schedules.get_by_child(w.child.id)).pattern == (
"1,3|0,2,4"
)
# constraint: malformed pattern CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2")
# delete: row gone, child present every day
assert await w.schedules.delete(w.child.id) is True
assert await w.schedules.get_by_child(w.child.id) is None
return True
assert _with_world(tmp_path / "m-schedules.db")(_body) is True
def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
found = await w.overrides.list_by_child_and_range(
w.child.id, D1, D2
)
assert [r.id for r in found] == [w.override.id]
# update path: delete + recreate (overrides are append-oriented)
assert await w.overrides.delete(w.override.id) is True
replacement = await w.overrides.create(
w.child.id, D1, D2, False, note="updated"
)
assert replacement.is_present is False
# constraint: end before start CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.overrides.create(w.child.id, D2, D1, True)
return True
assert _with_world(tmp_path / "m-overrides.db")(_body) is True
def test_matrix_quest_instances_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.instances.get(w.definition.id, D1)
assert fetched == w.instance
# update path: conflict refresh on the OPEN instance — same row
# id, snapshot columns (due_time) actually rewritten.
refreshed = await w.instances.upsert(
w.definition.id, w.child.id, D1, _stamp(), due_time="17:00"
)
assert refreshed.id == fetched.id, (
"the conflict path must refresh the existing row in place"
)
assert refreshed.due_time == "17:00"
assert fetched.due_time is None
assert await w.instances.get_by_id(fetched.id) is not None
return True
assert _with_world(tmp_path / "m-instances.db")(_body) is True
def test_matrix_quest_instances_constraints(tmp_path) -> None:
async def _body(w: World):
# D20 has no instance yet: a raw insert succeeds, then an
# identical second insert must fail on the UNIQUE constraint —
# proving the schema backs the DAO's idempotency.
await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()),
)
with pytest.raises(sqlite3.IntegrityError):
await w.database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, due_time, generated_at) "
"VALUES (?, ?, ?, NULL, ?)",
(w.definition.id, w.child.id, D20, _stamp()),
)
# delete future uncompleted removes D20's open instance
deleted = await w.instances.delete_future_uncompleted(
w.definition.id, D20
)
assert deleted == 1
return True
assert _with_world(tmp_path / "m-instances-constraints.db")(_body) is True
def test_matrix_quest_instances_upsert_idempotency(tmp_path) -> None:
async def _body(w: World):
first = await w.instances.upsert(
w.definition.id, w.child.id, D2, _stamp()
)
second = await w.instances.upsert(
w.definition.id, w.child.id, D2, _stamp(), due_time="17:00"
)
assert second.id == first.id
count = await w.database.fetch_one(
"SELECT COUNT(*) FROM quest_instances"
)
# Seed created one (D1) plus this one (D2) — no duplicate.
assert count == (2,)
return True
assert _with_world(tmp_path / "m-upsert-idempotent.db")(_body) is True
# ---------------------------------------------------------------------------
# completion_events: append-only by construction
# ---------------------------------------------------------------------------
def test_matrix_completion_events_append_read_and_no_mutation(
tmp_path,
) -> None:
async def _body(w: World):
# insert: append a completion then a reversal, then re-complete
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
)
await w.events.append(
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
actor_user_id="user-1",
)
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
)
# read: full ordered history + latest
history = await w.events.list_by_instance(w.instance.id)
assert [e.event_type for e in history] == [
"completed",
"uncompleted",
"completed",
]
latest = await w.events.get_latest_for_instance(w.instance.id)
assert latest.event_type == "completed"
assert latest.was_on_time is False
# constraint: unknown instance FK + bad actor pair surface as
# ValueError from the DAO's atomic validation
with pytest.raises(ValueError, match="does not exist"):
await w.events.append(
999, w.child.id, "completed", "user", _stamp(), True,
actor_user_id="user-1",
)
return history
assert _with_world(tmp_path / "m-events.db")(_body) is not None
def test_completion_events_absolutely_no_mutation_path() -> None:
"""The structural half of the done-condition: the DAO class exposes
no update/delete, AND no module in the package holds mutation SQL
for the table (already asserted package-wide in test_dao_instances;
re-asserted here so THIS suite stands alone in a clean checkout).
"""
import inspect
import re
from pathlib import Path
methods = {
name
for name, _ in inspect.getmembers(
CompletionEventsDao, inspect.isfunction
)
if not name.startswith("__")
}
assert methods == {
"append",
"list_by_instance",
"list_by_child_and_date_range",
"get_latest_for_instance",
}
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
mutation = re.compile(
r"(UPDATE(\s+OR\s+\w+)?|DELETE\s+FROM)\s+"
r"((main|temp)\s*\.\s*)?[`'\"]*(\[)?completion_events\b",
re.IGNORECASE,
)
offenders = [
py.name
for py in sorted(package.glob("*.py"))
if py.name != "__pycache__" and mutation.search(py.read_text())
]
assert offenders == []
-622
View File
@@ -1,622 +0,0 @@
"""Tests for dao_presence.py: presence schedules and overrides DAO."""
from __future__ import annotations
import asyncio
import sqlite3
import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceScheduleRecord,
PresenceSchedulesDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
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
NOW = "2026-09-14T12:00:00+00:00"
async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
overrides = PresenceOverridesDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, schedules, overrides, children, child
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database, schedules, overrides, children, child = await _prepare(
tmp_path / name
)
try:
return await body(
database, schedules, overrides, children, child
)
finally:
await database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# presence_schedules: upsert by child
# ---------------------------------------------------------------------------
def test_upsert_creates_then_updates_same_row(tmp_path) -> None:
"""Second upsert for the same child UPDATES, not raises."""
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2,4|1,3"
)
assert isinstance(first, PresenceScheduleRecord)
assert first.child_id == child.id
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2,4"
)
assert second.id == first.id, "upsert must reuse the existing row"
fetched = await schedules.get_by_child(child.id)
assert fetched.anchor_date == "2026-09-14"
assert fetched.pattern == "1,3|0,2,4"
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
)
assert rows == (1,)
return second
_with_db(tmp_path, "upsert.db")(_body)
def test_upsert_typed_record_fields(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await schedules.upsert_by_child(
child.id, 4, "2026-09-07", "0|1|2|3"
)
assert record.id == 1
assert record.child_id == child.id
assert record.cycle_length_weeks == 4
assert record.anchor_date == "2026-09-07"
assert record.pattern == "0|1|2|3"
return record
_with_db(tmp_path, "upsert-fields.db")(_body)
def test_upsert_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.upsert_by_child(
999, 2, "2026-09-07", "0,2|1,3"
)
return None
_with_db(tmp_path, "upsert-bad-child.db")(_body)
def test_upsert_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await schedules.upsert_by_child(child.id, 2, bad, "0|1")
return None
_with_db(tmp_path, "upsert-bad-date.db")(_body)
def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2"
)
return None
_with_db(tmp_path, "upsert-bad-pattern.db")(_body)
def test_upsert_two_children_have_distinct_rows(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
other = await children.create("Bo", NOW)
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2")
mine = await schedules.get_by_child(child.id)
theirs = await schedules.get_by_child(other.id)
assert mine.id != theirs.id
assert mine.pattern == "0,2|1,3"
assert theirs.pattern == "1,3|0,2"
return mine
_with_db(tmp_path, "upsert-two-children.db")(_body)
def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
"""Racing upserts for one child both succeed; exactly one row.
Deterministic interleaving: a controllable executor gates task A's
job AFTER it has acquired the connection lock and begun its
transaction, so task B is created while A provably owns the
transaction and must queue on the lock. Both upserts land, and
the table holds exactly one row (the ON CONFLICT upsert, not two
plain INSERTs, is what avoids a UNIQUE violation).
"""
async def _main():
from unittest.mock import MagicMock
hass = MagicMock()
# Gate exactly ONE job: the first _fetch_one issued after
# arming, which is task A's child-existence SELECT inside its
# transaction. The transaction's BEGIN runs first and is NOT
# gated, so when the gated fetch signals, BEGIN has executed
# and SQLite is inside the open transaction (A holds the
# connection lock from BEGIN until commit). Everything before
# arming (open, migrations, child create)
# and everything after (B's jobs, A's remaining jobs, reads)
# pass straight through.
armed = {"active": False, "gated": False}
gate_open = asyncio.Event()
a_started = asyncio.Event()
async def _gated_executor(fn, *args):
# Gate the SECOND gated-window job: the first is the
# transaction's BEGIN (submitted and awaited by
# transaction() before upsert_by_child's child-SELECT
# runs). Pausing the child-existence SELECT proves the
# BEGIN has EXECUTED — SQLite is actually inside the open
# transaction, with A holding the connection lock — when
# task B gets scheduled and must queue.
if armed["active"]:
name = getattr(fn, "__name__", "")
if name == "_fetch_one" and not armed["gated"]:
armed["gated"] = True
a_started.set()
await gate_open.wait()
return fn(*args)
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass)
await database.open(tmp_path / "upsert-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
child = await children.create("Ada", NOW)
armed["active"] = True
task_a = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
)
)
# Wait until task A's child-existence SELECT (inside its
# open transaction, BEGIN already executed) is paused, then
# schedule task B so it must queue behind the open
# transaction.
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2"
)
)
await asyncio.sleep(0)
# Release A; B's upsert must wait for A's commit and then
# land as the ON CONFLICT update path.
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.id == record_b.id
fetched = await schedules.get_by_child(child.id)
assert fetched is not None
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
)
assert rows == (1,)
return fetched
finally:
await database.close()
fetched = _run(_main())
assert fetched.pattern in {"0,2|1,3", "1,3|0,2"}
# ---------------------------------------------------------------------------
# presence_schedules: get / delete
# ---------------------------------------------------------------------------
def test_get_by_child_missing_schedule_returns_none(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.get_by_child(child.id)
assert _with_db(tmp_path, "get-none.db")(_body) is None
def test_get_by_child_unknown_child_raises(tmp_path) -> None:
"""None must mean ONLY 'no schedule row': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so get raises instead.
"""
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.get_by_child(999)
return None
_with_db(tmp_path, "get-unknown.db")(_body)
def test_delete_removes_and_returns_true_then_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
assert await schedules.delete(child.id) is True
assert await schedules.get_by_child(child.id) is None
assert await schedules.delete(child.id) is False
return None
_with_db(tmp_path, "delete.db")(_body)
def test_delete_child_without_schedule_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.delete(child.id)
assert _with_db(tmp_path, "delete-none.db")(_body) is False
def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
)
await schedules.delete(child.id)
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
)
assert second.id != first.id
return second
_with_db(tmp_path, "delete-recreate.db")(_body)
# ---------------------------------------------------------------------------
# presence_overrides: create / list / delete
# ---------------------------------------------------------------------------
def test_override_create_returns_typed_record(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
child.id, "2026-12-21", "2027-01-04", False,
note="Holiday abroad",
)
assert isinstance(record, PresenceOverrideRecord)
assert record.child_id == child.id
assert record.start_date == "2026-12-21"
assert record.end_date == "2027-01-04"
assert record.is_present is False
assert record.note == "Holiday abroad"
return record
_with_db(tmp_path, "override-create.db")(_body)
def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-14", "2026-09-14", True
)
assert record.start_date == record.end_date
assert record.is_present is True
assert record.note is None
return record
_with_db(tmp_path, "override-single-day.db")(_body)
def test_override_create_unknown_child_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await overrides.create(
999, "2026-09-14", "2026-09-14", True
)
return None
_with_db(tmp_path, "override-bad-child.db")(_body)
def test_override_create_malformed_dates_raise(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
for bad in ("not-a-date", "2026-9-14", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.create(child.id, bad, "2026-09-14", True)
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.create(child.id, "2026-09-14", bad, True)
return None
_with_db(tmp_path, "override-bad-dates.db")(_body)
def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await overrides.create(
child.id, "2026-09-14", "2026-09-13", True
)
return None
_with_db(tmp_path, "override-end-before-start.db")(_body)
def test_override_list_by_child_and_range_filters_correctly(
tmp_path,
) -> None:
async def _body(database, schedules, overrides, children, child):
await overrides.create(
child.id, "2026-09-01", "2026-09-05", False, note="before"
)
inside = await overrides.create(
child.id, "2026-09-10", "2026-09-12", False, note="inside"
)
overlapping = await overrides.create(
child.id, "2026-09-20", "2026-09-25", True, note="overlap"
)
after = await overrides.create(
child.id, "2026-10-01", "2026-10-02", False, note="after"
)
found = await overrides.list_by_child_and_range(
child.id, "2026-09-09", "2026-09-22"
)
assert [r.id for r in found] == [inside.id, overlapping.id]
# Inclusive boundaries: a range touching the override's edges
# still matches.
edge = await overrides.list_by_child_and_range(
child.id, "2026-09-20", "2026-09-20"
)
assert [r.id for r in edge] == [overlapping.id]
return found
_with_db(tmp_path, "override-list-range.db")(_body)
def test_override_list_orders_by_start_date(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
late = await overrides.create(
child.id, "2026-09-20", "2026-09-21", True
)
early = await overrides.create(
child.id, "2026-09-10", "2026-09-11", False
)
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert [r.id for r in found] == [early.id, late.id]
return found
_with_db(tmp_path, "override-list-order.db")(_body)
def test_override_list_per_child_isolation(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-09-10", "2026-09-11", True)
await overrides.create(other.id, "2026-09-10", "2026-09-11", False)
mine = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert len(mine) == 1
assert mine[0].is_present is True
return mine
_with_db(tmp_path, "override-isolation.db")(_body)
def test_override_list_malformed_range_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.list_by_child_and_range(
child.id, "oops", "2026-09-30"
)
with pytest.raises(ValueError, match="on or after"):
await overrides.list_by_child_and_range(
child.id, "2026-09-30", "2026-09-01"
)
return None
_with_db(tmp_path, "override-list-bad-range.db")(_body)
def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await overrides.list_by_child_and_range(
999, "2026-09-01", "2026-09-30"
)
assert _with_db(tmp_path, "override-list-unknown.db")(_body) == []
def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
assert await overrides.delete(record.id) is True
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert found == []
assert await overrides.delete(record.id) is False
return None
_with_db(tmp_path, "override-delete.db")(_body)
def test_override_delete_missing_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await overrides.delete(999)
assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False
def test_override_delete_leaves_other_overrides(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
second = await overrides.create(
child.id, "2026-09-20", "2026-09-21", False
)
assert await overrides.delete(first.id) is True
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert [r.id for r in found] == [second.id]
return found
_with_db(tmp_path, "override-delete-others.db")(_body)
def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting the schedule does not touch overrides and vice versa."""
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
override = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
assert await schedules.delete(child.id) is True
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert [r.id for r in found] == [override.id]
return found
_with_db(tmp_path, "independent.db")(_body)
# ---------------------------------------------------------------------------
# No SQL for these tables outside the DAO module
# ---------------------------------------------------------------------------
def test_presence_sql_lives_only_in_dao_module() -> None:
"""Guardrail: presence_schedules/presence_overrides SQL may appear
only in the DAO module (and the schema DDL declarations).
RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/
JOIN pattern, with exact repo-relative exclusions justified by
role: schema.py declares the DDL, migrations.py applies it, and
the test files for exactly those layers verify DDL, not data
paths.
"""
import re
from pathlib import Path
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
repo_root = package.parent.parent
scan_roots = [package, repo_root / "tests"]
allowed = {
"custom_components/nestquest/dao_presence.py", # this DAO
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_presence.py", # this file, scanned separately
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_schedules|presence_overrides)\b",
re.IGNORECASE,
)
offenders: list[str] = []
for root in scan_roots:
for py in sorted(root.rglob("*.py")):
relative = py.relative_to(repo_root).as_posix()
if relative in allowed:
continue
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching presence_schedules/presence_overrides leaked "
f"into: {offenders}"
)
def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exemptions are EXACT lines, not whole-function spans: the
two single-statement COUNT(*) row probes (the only honest way to
prove 'exactly one row exists' without a listing method) and the
guard functions' own regex spans. Any other raw SQL added anywhere
in this file fails the guard.
"""
import ast
import re
from pathlib import Path
path = Path(__file__)
text = path.read_text()
lines = text.splitlines(keepends=True)
tree = ast.parse(text)
guard_names = {
"test_presence_sql_lives_only_in_dao_module",
"test_dao_presence_test_file_uses_dao_not_raw_table_sql",
}
excluded: set[int] = set()
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if node.name in guard_names:
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
continue
# Narrow probe exemption: only the string-literal line holding
# the sanctioned COUNT(*) query, not the enclosing function.
for sub in ast.walk(node):
if (
isinstance(sub, ast.Constant)
and isinstance(sub.value, str)
and sub.value.strip()
== "SELECT COUNT(*) FROM presence_schedules"
and sub.lineno is not None
):
excluded.add(sub.lineno)
remaining = "".join(
line
for number, line in enumerate(lines, start=1)
if number not in excluded
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)"
r"\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
"tests must go through the DAO, not raw SQL, for these tables"
)
+80 -303
View File
@@ -1,4 +1,4 @@
"""Tests for dao_rules.py: typed DAO for schedule_rules and quest_definitions."""
"""Tests for dao_rules.py: typed DAO for schedule_rules and task_definitions."""
from __future__ import annotations
import asyncio
@@ -10,9 +10,8 @@ from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRulesDao,
QuestDefinitionRecord,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
TaskDefinitionRecord,
TaskDefinitionsDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
@@ -38,7 +37,7 @@ async def _prepare(path) -> tuple:
await database.open(path)
await apply_migrations(database)
rules = ScheduleRulesDao(database)
definitions = QuestDefinitionsDao(database)
definitions = TaskDefinitionsDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, rules, definitions, children, child
@@ -267,7 +266,7 @@ def test_rule_delete_unreferenced_succeeds(tmp_path) -> None:
def test_rule_delete_referenced_is_rejected(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await definitions.create("Brush teeth", rule.id, NOW)
await definitions.create("Brush teeth", child.id, rule.id, NOW)
assert await rules.delete_if_unreferenced(rule.id) is False
fetched = await rules.get(rule.id)
assert fetched is not None, (
@@ -289,7 +288,7 @@ def test_rule_delete_after_last_reference_removed_succeeds(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"Brush teeth", rule.id, NOW
"Brush teeth", child.id, rule.id, NOW
)
assert await rules.delete_if_unreferenced(rule.id) is False
await definitions.set_active(definition.id, False)
@@ -323,7 +322,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
await apply_migrations(database)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = QuestDefinitionsDao(database)
definitions = TaskDefinitionsDao(database)
child = await children.create("Ada", NOW)
rule_a = await rules.create("daily", "2026-09-14")
@@ -336,12 +335,12 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
import custom_components.nestquest.dao_rules as dao_rules
original_validate = (
dao_rules.QuestDefinitionsDao._validate_rule_exists
dao_rules.TaskDefinitionsDao._validate_assignable
)
release = asyncio.Event()
started = asyncio.Event()
async def _pausing_validate(self, rule_id):
async def _pausing_validate(self, child_id, rule_id):
# Run the real validation FIRST (this is the
# time-of-check), THEN pause: the delete task gets
# scheduled while the create sits between its
@@ -349,17 +348,19 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# transaction must keep the delete queued until the
# insert commits, so the create must win the race
# and the delete must be rejected as referenced.
await original_validate(self, rule_id)
await original_validate(self, child_id, rule_id)
if rule_id == rule_a.id and not started.is_set():
started.set()
await release.wait()
dao_rules.QuestDefinitionsDao._validate_rule_exists = (
dao_rules.TaskDefinitionsDao._validate_assignable = (
_pausing_validate
)
try:
create_task = asyncio.ensure_future(
definitions.create("Brush teeth", rule_a.id, NOW)
definitions.create(
"Brush teeth", child.id, rule_a.id, NOW
)
)
await started.wait()
delete_task = asyncio.ensure_future(
@@ -372,7 +373,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
delete_task, create_task, return_exceptions=True
)
finally:
dao_rules.QuestDefinitionsDao._validate_assignable = (
dao_rules.TaskDefinitionsDao._validate_assignable = (
original_validate
)
else:
@@ -381,7 +382,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
async def _create():
return await definitions.create(
"Brush teeth", rule_a.id, NOW
"Brush teeth", child.id, rule_a.id, NOW
)
delete_task = asyncio.ensure_future(_delete())
@@ -404,7 +405,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
assert rule_row is None
else:
# Create won: the delete was rejected, both rows exist.
assert isinstance(creation, QuestDefinitionRecord), (
assert isinstance(creation, TaskDefinitionRecord), (
f"create must succeed, got {creation!r}"
)
assert rule_row is not None
@@ -424,7 +425,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
# ---------------------------------------------------------------------------
# quest_definitions: create / get
# task_definitions: create / get
# ---------------------------------------------------------------------------
@@ -433,24 +434,20 @@ def test_definition_create_returns_typed_record(tmp_path) -> None:
rule = await rules.create("weekly", "2026-09-14", weekday_set="0,2")
definition = await definitions.create(
"Brush teeth",
child.id,
rule.id,
NOW,
description="Morning and night",
icon="mdi:tooth",
due_time="08:00",
assignee_child_ids=[child.id],
)
assert isinstance(definition, QuestDefinitionRecord)
assert isinstance(definition, TaskDefinitionRecord)
assert definition.title == "Brush teeth"
assert definition.child_id == child.id
assert definition.schedule_rule_id == rule.id
assert definition.due_time == "08:00"
assert definition.is_active is True
assert definition.created_at == NOW
assert not hasattr(definition, "child_id"), (
"D-008: assignment lives in quest_definition_assignees"
)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id]
return definition
_with_db(tmp_path, "definition-create.db")(_body)
@@ -463,30 +460,26 @@ def test_definition_get_missing_returns_none(tmp_path) -> None:
assert _with_db(tmp_path, "definition-get-missing.db")(_body) is None
def test_definition_create_unknown_assignee_raises_value_error(
def test_definition_create_unknown_child_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
with pytest.raises(ValueError, match="does not exist"):
await definitions.create(
"X", rule.id, NOW, assignee_child_ids=[999]
)
await definitions.create("X", 999, rule.id, NOW)
return None
_with_db(tmp_path, "definition-bad-child.db")(_body)
def test_definition_create_inactive_assignee_raises_value_error(
def test_definition_create_inactive_child_raises_value_error(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
await children.set_active(child.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.create(
"X", rule.id, NOW, assignee_child_ids=[child.id]
)
await definitions.create("X", child.id, rule.id, NOW)
return None
_with_db(tmp_path, "definition-inactive-child.db")(_body)
@@ -497,14 +490,14 @@ def test_definition_create_unknown_rule_raises_value_error(
) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.create("X", 999, NOW)
await definitions.create("X", child.id, 999, NOW)
return None
_with_db(tmp_path, "definition-bad-rule.db")(_body)
# ---------------------------------------------------------------------------
# quest_definitions: list by child / list active
# task_definitions: list by child / list active
# ---------------------------------------------------------------------------
@@ -512,11 +505,9 @@ def test_definition_list_by_child_filters_and_orders(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other_child = await children.create("Bo", NOW)
await definitions.create("A", rule.id, NOW, assignee_child_ids=[child.id])
await definitions.create("B", rule.id, NOW, assignee_child_ids=[child.id])
await definitions.create(
"C", rule.id, NOW, assignee_child_ids=[other_child.id]
)
await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
await definitions.create("C", other_child.id, rule.id, NOW)
mine = await definitions.list_by_child(child.id)
assert [d.title for d in mine] == ["B", "A"]
return mine
@@ -536,8 +527,8 @@ def test_definition_list_by_child_missing_child_returns_empty(
def test_definition_list_active_excludes_inactive(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
first = await definitions.create("A", rule.id, NOW)
await definitions.create("B", rule.id, NOW)
first = await definitions.create("A", child.id, rule.id, NOW)
await definitions.create("B", child.id, rule.id, NOW)
await definitions.set_active(first.id, False)
active = await definitions.list_active()
assert [d.title for d in active] == ["B"]
@@ -548,7 +539,7 @@ def test_definition_list_active_excludes_inactive(tmp_path) -> None:
# ---------------------------------------------------------------------------
# quest_definitions: update / set_active / set_assignee
# task_definitions: update / set_active / set_assignee
# ---------------------------------------------------------------------------
@@ -556,7 +547,7 @@ def test_definition_update_fields_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A", rule.id, NOW, due_time="08:00"
"A", child.id, rule.id, NOW, due_time="08:00"
)
assert await definitions.update(
definition.id,
@@ -581,11 +572,9 @@ def test_definition_update_cannot_change_assignee_or_active(
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
definition = await definitions.create("A", child.id, rule.id, NOW)
# title= / child_id= / is_active= are NOT update() parameters;
# this asserts the API shape: assignment and activation have
# this asserts the API shape: reassignment and activation have
# their own explicit methods.
import inspect
@@ -601,10 +590,7 @@ def test_definition_update_cannot_change_assignee_or_active(
definition.id, title="A2"
) == 1
fetched = await definitions.get(definition.id)
assert [c.id for c in await definitions.list_assignees(definition.id)] == [
child.id
]
assert other.id not in [c.id for c in await definitions.list_assignees(definition.id)]
assert fetched.child_id == child.id
assert fetched.is_active is True
return fetched
@@ -614,7 +600,7 @@ def test_definition_update_cannot_change_assignee_or_active(
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
return await definitions.update(definition.id)
assert _with_db(tmp_path, "definition-update-empty.db")(_body) == 0
@@ -626,6 +612,7 @@ def test_definition_update_can_clear_optional_fields(tmp_path) -> None:
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A",
child.id,
rule.id,
NOW,
description="d",
@@ -659,7 +646,7 @@ def test_definition_update_missing_returns_zero(tmp_path) -> None:
def test_definition_set_active_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
definition = await definitions.create("A", child.id, rule.id, NOW)
assert await definitions.set_active(definition.id, False) == 1
fetched = await definitions.get(definition.id)
assert fetched.is_active is False
@@ -686,7 +673,7 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
methods = {
name
for name, _ in inspect.getmembers(
QuestDefinitionsDao, inspect.isfunction
TaskDefinitionsDao, inspect.isfunction
)
}
assert "delete" not in methods
@@ -696,244 +683,78 @@ def test_definition_no_delete_method_exists(tmp_path) -> None:
_with_db(tmp_path, "definition-no-delete.db")(_body)
def test_definition_add_assignee_round_trip(tmp_path) -> None:
def test_definition_set_assignee_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert sorted(c.id for c in assignees) == sorted([child.id, other.id])
# Idempotent: re-adding an existing assignee stays a no-op.
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert sorted(c.id for c in assignees) == sorted([child.id, other.id])
return assignees
definition = await definitions.create("A", child.id, rule.id, NOW)
assert await definitions.set_assignee(definition.id, other.id) == 1
fetched = await definitions.get(definition.id)
assert fetched.child_id == other.id
return fetched
_with_db(tmp_path, "definition-add-assignee.db")(_body)
_with_db(tmp_path, "definition-set-assignee.db")(_body)
def test_definition_add_assignee_inactive_child_raises(tmp_path) -> None:
def test_definition_set_assignee_inactive_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
definition = await definitions.create("A", child.id, rule.id, NOW)
await children.set_active(other.id, False)
with pytest.raises(ValueError, match="inactive"):
await definitions.add_assignee(definition.id, other.id)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id], (
"failed assignment must leave the current assignees intact"
await definitions.set_assignee(definition.id, other.id)
fetched = await definitions.get(definition.id)
assert fetched.child_id == child.id, (
"failed reassignment must leave the current assignee intact"
)
return assignees
return fetched
_with_db(tmp_path, "definition-assignee-inactive.db")(_body)
def test_definition_add_assignee_unknown_child_raises(tmp_path) -> None:
def test_definition_set_assignee_unknown_child_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
definition = await definitions.create("A", child.id, rule.id, NOW)
with pytest.raises(ValueError, match="does not exist"):
await definitions.add_assignee(definition.id, 999)
await definitions.set_assignee(definition.id, 999)
return None
_with_db(tmp_path, "definition-assignee-unknown.db")(_body)
def test_definition_add_assignee_missing_definition_raises(tmp_path) -> None:
def test_definition_set_assignee_missing_definition_returns_zero(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.add_assignee(999, child.id)
return None
_with_db(tmp_path, "definition-assignee-missing.db")(_body)
def test_definition_remove_assignee_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id, other.id]
)
assert await definitions.remove_assignee(definition.id, other.id) is True
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [child.id]
# Removing a non-assignee reports False.
assert await definitions.remove_assignee(definition.id, other.id) is False
return assignees
return await definitions.set_assignee(999, other.id)
_with_db(tmp_path, "definition-remove-assignee.db")(_body)
assert _with_db(tmp_path, "definition-assignee-missing.db")(_body) == 0
def test_definition_list_assignees_orders_by_child_sort_order(
def test_definition_set_assignee_leaves_history_rows_alone(
tmp_path,
) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
second = await children.create("Bo", NOW)
third = await children.create("Cleo", NOW)
await children.reorder([third.id, child.id, second.id])
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[second.id, child.id, third.id]
)
assignees = await definitions.list_assignees(definition.id)
assert [c.id for c in assignees] == [third.id, child.id, second.id]
return assignees
_with_db(tmp_path, "definition-assignees-order.db")(_body)
def test_definition_upsert_window_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
window = await definitions.upsert_window(
definition.id, "morning", due_time="09:00"
)
assert window == QuestDefinitionWindowRecord(
definition.id, "morning", "09:00"
)
# Upsert: re-declaring the same window updates in place, never
# duplicates (one row per definition+window).
updated = await definitions.upsert_window(
definition.id, "morning", due_time="09:30"
)
assert updated.due_time == "09:30"
rows = await definitions.list_windows(definition.id)
assert [w.window for w in rows] == ["morning"]
# All three windows list in const order regardless of insertion.
await definitions.upsert_window(definition.id, "evening")
await definitions.upsert_window(definition.id, "afternoon")
rows = await definitions.list_windows(definition.id)
assert [w.window for w in rows] == [
"morning",
"afternoon",
"evening",
]
assert rows[1].due_time is None
return rows
_with_db(tmp_path, "definition-window-roundtrip.db")(_body)
def test_definition_window_unknown_name_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
with pytest.raises(ValueError, match="window must be one of"):
await definitions.upsert_window(definition.id, "noon")
with pytest.raises(ValueError, match="window must be one of"):
await definitions.remove_window(definition.id, "MORNING")
return None
_with_db(tmp_path, "definition-window-bad-name.db")(_body)
def test_definition_window_bad_due_time_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
with pytest.raises(ValueError, match="HH:MM"):
await definitions.upsert_window(
definition.id, "morning", due_time="9:30"
)
with pytest.raises(ValueError, match="HH:MM"):
await definitions.upsert_window(
definition.id, "morning", due_time="25:00"
)
return None
_with_db(tmp_path, "definition-window-bad-time.db")(_body)
def test_definition_window_missing_definition_raises(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
with pytest.raises(ValueError, match="does not exist"):
await definitions.upsert_window(999, "morning")
return None
_with_db(tmp_path, "definition-window-missing.db")(_body)
def test_definition_remove_window_round_trip(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
await definitions.upsert_window(definition.id, "morning")
assert await definitions.remove_window(
definition.id, "morning"
) is True
assert await definitions.list_windows(definition.id) == []
# Removing an absent window reports False.
assert await definitions.remove_window(
definition.id, "morning"
) is False
return None
_with_db(tmp_path, "definition-window-remove.db")(_body)
def test_definition_unassigned_has_no_assignees(tmp_path) -> None:
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
definition = await definitions.create("A", rule.id, NOW)
assert await definitions.list_assignees(definition.id) == []
return None
_with_db(tmp_path, "definition-no-assignees.db")(_body)
def test_definition_shared_by_multiple_children_via_list_by_child(
tmp_path,
) -> None:
"""D-008: one definition covering several children appears for
every assignee through list_by_child, once each."""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
second = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id, second.id]
)
for assignee in (child, second):
mine = await definitions.list_by_child(assignee.id)
assert [d.id for d in mine] == [definition.id]
return definition
_with_db(tmp_path, "definition-shared.db")(_body)
def test_definition_assignment_leaves_history_rows_alone(
tmp_path,
) -> None:
"""Assignment edits write the assignees table only: any instances
that already exist keep their generation-time child_id (Feature 06
"""Reassignment writes the definition row only: any instances that
already exist keep their generation-time child_id (Feature 06
guardrail 'changes future instances only').
"""
async def _body(database, rules, definitions, children, child):
rule = await rules.create("daily", "2026-09-14")
other = await children.create("Bo", NOW)
definition = await definitions.create(
"A", rule.id, NOW, assignee_child_ids=[child.id]
)
definition = await definitions.create("A", child.id, rule.id, NOW)
# Simulate an already-generated instance (materialization owns
# this table, but its row shape proves assignment isolation).
# this table, but its row shape proves reassignment isolation).
await database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"INSERT INTO task_instances (definition_id, child_id, "
"due_date, generated_at) VALUES (?, ?, ?, ?)",
(definition.id, child.id, "2026-09-15", NOW),
)
await definitions.add_assignee(definition.id, other.id)
await definitions.remove_assignee(definition.id, child.id)
await definitions.set_assignee(definition.id, other.id)
row = await database.fetch_one(
"SELECT child_id FROM quest_instances WHERE definition_id = ?",
"SELECT child_id FROM task_instances WHERE definition_id = ?",
(definition.id,),
)
assert row == (child.id,)
@@ -948,7 +769,7 @@ def test_definition_assignment_leaves_history_rows_alone(
def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
"""Guardrail: schedule_rules/quest_definitions SQL may appear only
"""Guardrail: schedule_rules/task_definitions SQL may appear only
in the DAO modules (and the schema DDL declarations). RECURSIVE
scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/JOIN
pattern, with exact repo-relative exclusions justified by role:
@@ -968,19 +789,19 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
allowed = {
"custom_components/nestquest/dao_rules.py", # this DAO
"custom_components/nestquest/dao_instances.py", # validates the
# definition exists + fetches its assignee before generating an
# instance (the instance must snapshot the definition's child)
"custom_components/nestquest/schema.py", # declares the DDL
"custom_components/nestquest/migrations.py", # applies the DDL
"tests/test_schema.py", # tests the DDL
"tests/test_migrations.py", # tests migration application
"tests/test_dao_rules.py", # this file, scanned separately
"tests/test_dao_instances.py", # instances guard, own scope
# dao_children.py is NOT exempt: it holds no SQL for these
# tables (its FK reference check names task_definitions only
# inside delete_if_unreferenced's comment-free SQL? No — that
# lives HERE in dao_rules). Any future leak there must fail.
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
r"(schedule_rules|task_definitions)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -992,60 +813,16 @@ def test_rules_and_definitions_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching schedule_rules/quest_definitions/assignees leaked: "
f"SQL touching schedule_rules/task_definitions leaked into: "
f"{offenders}"
)
def test_dao_instances_test_file_uses_dao_not_raw_rules_sql() -> None:
"""Compensating self-scan for the test_dao_instances.py exemption:
that file may query quest_instances/completion_events raw (its own
guard's scope) but must go through the DAO for schedule_rules and
quest_definitions, except its single sanctioned definition-create
helper usage and guard spans.
"""
import ast
import re
from pathlib import Path
instances_test = Path(__file__).parent / "test_dao_instances.py"
text = instances_test.read_text()
lines = text.splitlines(keepends=True)
tree = ast.parse(text)
guard_names = {
"test_rules_and_definitions_sql_lives_only_in_dao_module",
"test_no_mutation_sql_for_completion_events_anywhere",
}
excluded: set[int] = set()
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if node.name in guard_names:
excluded.update(
range(node.lineno, (node.end_lineno or node.lineno) + 1)
)
remaining = "".join(
line
for number, line in enumerate(lines, start=1)
if number not in excluded
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
"test_dao_instances.py must go through the DAO, not raw SQL, "
"for schedule_rules/quest_definitions"
)
def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exceptions: the guard functions' own spans (their regexes
mention the names), and the reassignment-isolation test, which
legitimately touches quest_instances — a table this DAO does not
legitimately touches task_instances — a table this DAO does not
own — to prove the guardrail that reassignment leaves it alone.
"""
import ast
@@ -1077,7 +854,7 @@ def test_dao_rules_test_file_uses_dao_not_raw_table_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|quest_definitions|quest_definition_assignees|quest_definition_windows)\b",
r"FROM|JOIN)\s+[`'\"]*(\[)?(schedule_rules|task_definitions)\b",
re.IGNORECASE,
)
assert sql_pattern.search(remaining) is None, (
+2 -2
View File
@@ -247,12 +247,12 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
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"
"'task_definitions') ORDER BY name"
)
assert [row[0] for row in rows] == [
"admin_users",
"children",
"quest_definitions",
"schedule_rules",
"task_definitions",
]
await async_unload_entry(hass, entry)
-18
View File
@@ -56,24 +56,6 @@ def test_manifest_requirements(manifest: dict) -> None:
assert manifest.get("requirements") == []
def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None:
"""Verify documentation URL points at the public GitHub mirror."""
assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest"
def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None:
"""Verify issue_tracker URL points at the public GitHub mirror."""
assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues"
@pytest.mark.parametrize("key", ["documentation", "issue_tracker"])
def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None:
"""Verify manifest URLs use github.com rather than the private Gitea host."""
url = manifest.get(key, "")
assert url.startswith("https://github.com/talon2king/NestQuest")
assert "cubecraftlabs" not in url
@pytest.mark.parametrize(
"key",
["name", "version", "documentation", "issue_tracker", "codeowners"],
+41 -458
View File
@@ -17,10 +17,7 @@ from custom_components.nestquest.migrations import (
apply_migrations,
read_schema_version,
)
from custom_components.nestquest.schema import (
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
)
from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS
def _run(coro):
@@ -65,12 +62,10 @@ def _counts(database) -> dict[str, int]:
"children",
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"task_definitions",
"presence_schedules",
"presence_overrides",
"quest_instances",
"task_instances",
"completion_events",
)
counts = {}
@@ -81,16 +76,16 @@ def _counts(database) -> dict[str, int]:
# ---------------------------------------------------------------------------
# Fresh file: version 0 -> latest version
# Fresh file: version 0 -> version 1
# ---------------------------------------------------------------------------
def test_fresh_file_migrates_to_latest_version(tmp_path) -> None:
def test_fresh_file_migrates_to_version_1(tmp_path) -> None:
database = _open_db(tmp_path / "fresh.db")
try:
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
assert final == 1
assert _version(database) == 1
finally:
_run(database.close())
@@ -103,12 +98,10 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"children",
"admin_users",
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"task_definitions",
"presence_schedules",
"presence_overrides",
"quest_instances",
"task_instances",
"completion_events",
VERSION_TABLE,
}
@@ -142,397 +135,9 @@ def test_empty_version_table_reads_as_version_0(tmp_path) -> None:
_run(database.close())
def test_migration_list_shape() -> None:
"""Migration 1 is the v1 DDL; 2 and 3 are callable steps.
Nothing ever shipped, so migration 1 was rewritten pre-release
(D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner,
migration 3 brings pre-D-008 definitions to the multi-assignee
model, and migration 4 adds the windows table.
"""
def test_migration_1_is_the_full_v1_ddl() -> None:
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert len(MIGRATIONS) == 4
# ---------------------------------------------------------------------------
# Legacy dev database: task_* tables stamped version 1 -> quest_*
# ---------------------------------------------------------------------------
_LEGACY_DDL = [
"""
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
colour TEXT,
avatar_ref TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS admin_users (
ha_user_id TEXT PRIMARY KEY NOT NULL,
added_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS schedule_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_type TEXT NOT NULL,
interval INTEGER NOT NULL DEFAULT 1,
weekday_set TEXT,
day_of_month INTEGER,
nth_weekday INTEGER,
month INTEGER,
start_date TEXT NOT NULL,
end_date TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS task_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS presence_schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
cycle_length_weeks INTEGER NOT NULL,
anchor_date TEXT NOT NULL,
pattern TEXT NOT NULL
)
""",
"""
CREATE TABLE IF NOT EXISTS presence_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
child_id INTEGER NOT NULL REFERENCES children(id),
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
is_present INTEGER NOT NULL,
note TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS task_instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
child_id INTEGER NOT NULL REFERENCES children(id),
due_date TEXT NOT NULL,
due_time TEXT,
generated_at TEXT NOT NULL,
UNIQUE (definition_id, due_date)
)
""",
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL,
actor_source TEXT NOT NULL,
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER
)
""",
]
def _seed_legacy_database(database) -> None:
"""Create a database indistinguishable from a pre-rename dev file.
Applies the legacy task_* DDL, seeds one row in every renamed
table, and stamps version 1 exactly the way the old runner did.
"""
for sql in _LEGACY_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 1)"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ada', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO schedule_rules (rule_type, start_date) "
"VALUES ('daily', '2026-09-14')"
)
)
_run(
database.execute(
"INSERT INTO task_definitions (title, child_id, "
"schedule_rule_id, created_at) "
"VALUES ('Dishes', 1, 1, '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO task_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (1, 1, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
def test_legacy_task_tables_are_renamed_and_keep_their_rows(tmp_path) -> None:
database = _open_db(tmp_path / "legacy.db")
try:
_seed_legacy_database(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
tables = _tables(database)
assert "task_definitions" not in tables
assert "task_instances" not in tables
assert {"quest_definitions", "quest_instances"} <= tables
# Every seeded row survived the rename.
assert _run(database.fetch_one("SELECT COUNT(*) FROM children")) == (1,)
assert (
_run(
database.fetch_one(
"SELECT title FROM quest_definitions WHERE id = 1"
)
)
== ("Dishes",)
)
assert (
_run(
database.fetch_one(
"SELECT due_date FROM quest_instances WHERE id = 1"
)
)
== ("2026-09-15",)
)
# Migration 3 moved the legacy single-assignee column into the
# assignees table: the definition kept its child, the column is
# gone.
assignees = _run(
database.fetch_one(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = 1"
)
)
assert assignees == (1,)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {row[1] for row in columns}
# The untouched completion_events table still exists and is empty.
assert "completion_events" in tables
assert (
_run(database.fetch_one("SELECT COUNT(*) FROM completion_events"))
== (0,)
)
finally:
_run(database.close())
def test_renamed_instances_foreign_key_follows_the_rename(tmp_path) -> None:
database = _open_db(tmp_path / "legacy-fk.db")
try:
_seed_legacy_database(database)
_migrate(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)"))
referenced = {row[2] for row in fks}
assert "quest_definitions" in referenced
assert "task_definitions" not in referenced
# The renamed table is live: a new row validates its FKs.
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ben', '2026-09-14T00:00:00+00:00')"
)
)
with pytest.raises(sqlite3.IntegrityError):
_run(
database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (999, 2, '2026-09-16', "
"'2026-09-14T00:00:00+00:00')"
)
)
finally:
_run(database.close())
def test_legacy_rename_is_idempotent_across_restarts(tmp_path) -> None:
database = _open_db(tmp_path / "legacy-restart.db")
try:
_seed_legacy_database(database)
_migrate(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
tables = _tables(database)
assert "quest_definitions" in tables
assert "task_definitions" not in tables
assert (
_run(database.fetch_one("SELECT COUNT(*) FROM quest_definitions"))
== (1,)
)
finally:
_run(database.close())
def test_fresh_database_never_holds_legacy_table_names(tmp_path) -> None:
database = _open_db(tmp_path / "fresh-legacy.db")
try:
_migrate(database)
tables = _tables(database)
assert not any(name.startswith("task_") for name in tables)
assert _version(database) == len(MIGRATIONS)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Pre-D-008 database: quest_definitions with child_id stamped version 2
# ---------------------------------------------------------------------------
_V2_DEFINITIONS_DDL = [
"""
CREATE TABLE IF NOT EXISTS quest_definitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
child_id INTEGER NOT NULL REFERENCES children(id),
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
due_time TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
""",
]
def _seed_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file.
Fresh quest_* tables — but quest_definitions still carries the
single child_id column and no assignees table exists — stamped at
version 2, with definition and instance rows that must survive.
"""
for sql in SCHEMA_V1_STATEMENTS:
if "quest_definitions " in sql:
continue # replaced by the legacy-shape DDL below
_run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
database.execute(
f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (1, 2)"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Ada', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES ('Bo', '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO schedule_rules (rule_type, start_date) "
"VALUES ('daily', '2026-09-14')"
)
)
_run(
database.execute(
"INSERT INTO quest_definitions (title, child_id, "
"schedule_rule_id, created_at) "
"VALUES ('Dishes', 2, 1, '2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"due_date, generated_at) "
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
database = _open_db(tmp_path / "v2-upgrade.db")
try:
_seed_v2_multi_assignee_upgrade(database)
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
# The definition row survived and lost its child_id column.
row = _run(
database.fetch_one(
"SELECT title, schedule_rule_id, is_active "
"FROM quest_definitions WHERE id = 1"
)
)
assert row == ("Dishes", 1, 1)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definitions)")
)
assert "child_id" not in {c[1] for c in columns}
# The single assignee moved into the assignees table.
assignees = _run(
database.fetch_all(
"SELECT definition_id, child_id "
"FROM quest_definition_assignees ORDER BY child_id"
)
)
assert assignees == [(1, 2)]
# The instance survived, still referencing the definition.
instance = _run(
database.fetch_one(
"SELECT definition_id, child_id, due_date "
"FROM quest_instances WHERE id = 1"
)
)
assert instance == (1, 2, "2026-09-15")
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition
# insert carries no child column at all.
_run(
database.execute(
"INSERT INTO quest_definitions (title, schedule_rule_id, "
"created_at) VALUES ('Laundry', 1, "
"'2026-09-14T00:00:00+00:00')"
)
)
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (2, 1)"
)
)
finally:
_run(database.close())
assert len(MIGRATIONS) == 1
# ---------------------------------------------------------------------------
@@ -557,8 +162,8 @@ def test_second_run_applies_nothing_and_keeps_rows(tmp_path) -> None:
)
)
final = _migrate(database)
assert final == len(MIGRATIONS)
assert _version(database) == len(MIGRATIONS)
assert final == 1
assert _version(database) == 1
assert _counts(database)["children"] == 1
assert _counts(database)["admin_users"] == 1
finally:
@@ -623,7 +228,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
_migrate(database)
assert any(
f"already at version {len(MIGRATIONS)}" in r.getMessage()
"already at version 1" in r.getMessage()
for r in caplog.records
)
finally:
@@ -636,47 +241,33 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
def _failing_migrations(prior_statements: list[str]) -> list[list[str]]:
"""Migration list where the LAST entry fails mid-application.
Sized one entry longer than the real MIGRATIONS and padded with
harmless no-op fillers, so a fully-migrated database (already at
len(MIGRATIONS)) still has exactly one pending migration — the
failing one — to attempt.
"""
steps: list[list[str]] = [prior_statements]
steps += [
[
f"CREATE TABLE IF NOT EXISTS filler_{index} "
"(id INTEGER PRIMARY KEY)"
]
for index in range(len(MIGRATIONS) - 1)
]
steps.append(
"""Two-migration list where migration 2 fails mid-application."""
return [
prior_statements,
[
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)",
"CREATE TABLE broken (",
]
)
return steps
],
]
def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
database = _open_db(tmp_path / "rollback.db")
try:
# Manually simulate a fully-migrated database: run the real
# migration list, which stamps the latest version.
# Manually simulate a database at version 1: apply migration 1's
# statements directly and stamp version 1 through the runner.
_migrate(database)
version_row = _run(
database.fetch_one(f"SELECT version FROM {VERSION_TABLE}")
)
assert version_row == (len(MIGRATIONS),)
assert version_row == (1,)
with pytest.raises(sqlite3.OperationalError):
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
# Version stamp unchanged, statements from the failed migration
# absent, and the v1 tables untouched.
assert _version(database) == len(MIGRATIONS)
assert _version(database) == 1
assert "later_table" not in _tables(database)
assert _counts(database)["children"] == 0
assert _counts(database)["completion_events"] == 0
@@ -684,24 +275,21 @@ def test_failing_migration_rolls_back_to_prior_version(tmp_path) -> None:
_run(database.close())
def test_failing_migration_on_fresh_file_leaves_last_good_version(
tmp_path,
) -> None:
def test_failing_migration_on_fresh_file_leaves_version_0(tmp_path) -> None:
database = _open_db(tmp_path / "fresh-rollback.db")
try:
# The fresh file reaches the failing migration (the last one):
# earlier migrations commit and stamp before it fails and rolls
# back. The run aborts at the last fully-applied migration —
# not at version 0.
broken = _failing_migrations(SCHEMA_V1_STATEMENTS)
# The fresh file still reaches migration 2 (the failing one):
# migration 1 commits, stamping version 1, before migration 2
# fails and rolls back. The run aborts at version 1 — the last
# fully-applied migration — not at version 0.
with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken)
assert _version(database) == len(broken) - 1
# The failing migration's tables must not survive the attempt.
_migrate(database, _failing_migrations(SCHEMA_V1_STATEMENTS))
assert _version(database) == 1
# Migration 2's tables must not survive the failed attempt.
assert "later_table" not in _tables(database)
# Migration 1's tables do: it committed before the failure.
assert "children" in _tables(database)
assert "quest_instances" in _tables(database)
assert "task_instances" in _tables(database)
finally:
_run(database.close())
@@ -714,12 +302,12 @@ def test_failed_migration_can_be_retried_after_fix(tmp_path) -> None:
with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken)
# "Fix" the migration and retry: it now applies cleanly.
broken[-1] = [
broken[1] = [
"CREATE TABLE IF NOT EXISTS later_table (id INTEGER PRIMARY KEY)"
]
final = _migrate(database, broken)
assert final == len(broken)
assert _version(database) == len(broken)
assert final == 2
assert _version(database) == 2
assert "later_table" in _tables(database)
finally:
_run(database.close())
@@ -987,7 +575,7 @@ async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None:
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
assert await read_schema_version(database) == len(MIGRATIONS)
assert await read_schema_version(database) == 1
# The v1 tables exist because the runner applied migration 1.
names = {
row[0]
@@ -1038,7 +626,7 @@ async def test_options_reload_reopens_and_rechecks_version(
database_b = entry_reloaded.runtime_data.database
assert database_b is not database_a
try:
assert await read_schema_version(database_b) == len(MIGRATIONS)
assert await read_schema_version(database_b) == 1
row = await database_b.fetch_one("SELECT COUNT(*) FROM children")
assert row == (1,)
finally:
@@ -1050,14 +638,11 @@ async def test_setup_entry_failure_closes_database(
) -> 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).
Spies on the real wrapper instance: records whether close() ran and
whether the connection is left disconnected, 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
@@ -1082,10 +667,8 @@ async def test_setup_entry_failure_closes_database(
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
entry = make_entry()
with pytest_module.raises(ConfigEntryNotReady) as excinfo:
with pytest_module.raises(RuntimeError, match="boom"):
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"
-361
View File
@@ -1,361 +0,0 @@
"""Window-bound enforcement across every rule type (start_date/end_date).
Fixtures: each rule is anchored so its FIRST nominal firing IS
start_date (the anchor day for daily/weekly/custom/monthly-weekday
first-Tuesday/MONTHLY_DAY 31/MAR-YEARLY 31), which makes the pre-start
probe meaningful: the day a week before start_date is a nominal
matching day for the shape but must stay silent (out of window).
"""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def _tuesday(year: int, month: int, nth: int) -> datetime.date:
"""The nth Tuesday of the month (nth >= 1)."""
day = datetime.date(year, month, 1)
while day.weekday() != 1:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _friday(year: int, month: int, nth: int) -> datetime.date:
day = datetime.date(year, month, 1)
while day.weekday() != 4:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _month_end(year: int, month: int) -> datetime.date:
if month == 12:
return datetime.date(year, 12, 31)
return datetime.date(year, month + 1, 1) - datetime.timedelta(days=1)
#: One representative rule per type. Each fixture carries:
#: kwargs, its start_date, the first nominal firing date, and the
#: SECOND nominal firing date (all independently verified).
#: - DAILY: start Mar 1; fires daily; second firing Mar 2.
#: - WEEKLY (Tuesdays): start Tue Mar 3; second firing Tue Mar 10.
#: - CUSTOM_DAYS (Wed + Fri): start Wed Mar 4; second firing Fri Mar 6.
#: - MONTHLY_DAY (31st): start Tue Mar 31; second firing Apr 30 (clamp).
#: - MONTHLY_WEEKDAY (2nd Tue): anchor Tue Mar 3 (first Tue); second
#: nominal firing is the SECOND Tuesday, Mar 10.
#: - YEARLY (Mar 31): start Tue Mar 31; second firing Mar 31 2027.
BOUNDARY_RULES = [
{
"kwargs": {"rule_type": RuleType.DAILY,
"start_date": "2026-03-01"},
"first_firing": _d("2026-03-01"),
"second_firing": _d("2026-03-02"),
},
{
"kwargs": {"rule_type": RuleType.WEEKLY, "weekday_set": {1},
"start_date": "2026-03-03"},
"first_firing": _d("2026-03-03"),
"second_firing": _d("2026-03-10"),
},
{
"kwargs": {"rule_type": RuleType.CUSTOM_DAYS,
"weekday_set": {2, 4}, "start_date": "2026-03-04"},
"first_firing": _d("2026-03-04"),
"second_firing": _d("2026-03-06"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_DAY,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2026-04-30"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_WEEKDAY,
"nth_weekday": 2, "nth_weekday_weekday": 1,
"start_date": "2026-03-03"},
# The rule fires on SECOND Tuesdays: Mar 3 is the FIRST Tue
# (in-window but silent); the first nominal FIRING is Mar 10.
"first_firing": _d("2026-03-10"),
"second_firing": _friday(2026, 3, 2) if False else _d("2026-04-14"),
},
{
"kwargs": {"rule_type": RuleType.YEARLY, "month": 3,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2027-03-31"),
},
]
# ---------------------------------------------------------------------------
# start_date bound: nothing fires before it, for EVERY rule type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_no_firing_before_start_date(index) -> None:
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
before_start = _d(rule.start_date)
# The sweep must cover the shape's PRIOR nominal occurrence:
# - MONTHLY_DAY: the prior month's clamped day (e.g. Feb 28/31)
# - YEARLY: the prior year's nominal day
# - WEEKLY/CUSTOM_DAYS: the prior week's matching weekday
# - MONTHLY_WEEKDAY: the prior month's nth weekday
if rule.rule_type is RuleType.MONTHLY_DAY:
sweep_from = _month_end(2026, 2) # Feb 28, a nominal clamp day
elif rule.rule_type is RuleType.YEARLY:
sweep_from = datetime.date(
_d(rule.start_date).year - 1, rule.month, rule.day_of_month
)
elif rule.rule_type is RuleType.MONTHLY_WEEKDAY:
sweep_from = _tuesday(2026, 2, 2) # prior month's 2nd Tuesday
elif rule.rule_type is RuleType.WEEKLY:
sweep_from = before_start - datetime.timedelta(days=7)
elif rule.rule_type is RuleType.CUSTOM_DAYS:
sweep_from = before_start - datetime.timedelta(days=7)
else:
sweep_from = before_start - datetime.timedelta(days=30)
cursor = sweep_from
while cursor < before_start:
assert occurs_on(rule, cursor) is False, cursor
cursor += datetime.timedelta(days=1)
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_first_nominal_firing_is_on_or_after_start(index) -> None:
"""The first nominal firing must actually fire: the window must not
exclude a date at or after start_date."""
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
first = fixture["first_firing"]
assert occurs_on(rule, first) is True, first
# ---------------------------------------------------------------------------
# end_date bound: nothing fires after it
# ---------------------------------------------------------------------------
def test_no_firing_after_end_date_all_types() -> None:
window_end = _d("2026-09-30")
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-09-30", **fixture["kwargs"]
)
# Sweep every day in the week after window_end: none may fire,
# regardless of whether that date would nominally match.
for offset in range(1, 8):
after = window_end + datetime.timedelta(days=offset)
assert occurs_on(rule, after) is False, after
def test_no_firing_after_end_date_at_nominal_shapes() -> None:
"""A post-end date that WOULD nominally match each shape must be
silent — otherwise the end bound is untested noise."""
rule_monthly = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31", end_date="2026-04-30",
)
# Apr 30 is INSIDE the window (fires: clamp); May 31 would nominally
# match but is after end_date: silent.
assert occurs_on(rule_monthly, _d("2026-04-30")) is True
assert occurs_on(rule_monthly, _d("2026-05-31")) is False
rule_yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31", end_date="2027-03-31",
)
# Mar 31 2027 is inside; Mar 31 2028 would match but is past end.
assert occurs_on(rule_yearly, _d("2027-03-31")) is True
assert occurs_on(rule_yearly, _d("2028-03-31")) is False
rule_weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03", end_date="2026-10-05",
)
# Tue Oct 6 would nominally match but is after end_date (Oct 5 is a
# Monday, not in the set).
assert occurs_on(rule_weekly, _d("2026-09-29")) is True
assert occurs_on(rule_weekly, _d("2026-10-06")) is False
rule_monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03",
end_date="2026-10-05",
)
# The next nominal second Tuesday (Oct 13) is past end_date: silent.
assert occurs_on(rule_monthly_weekday, _d("2026-09-08")) is True
assert occurs_on(rule_monthly_weekday, _d("2026-10-13")) is False
def test_no_end_date_means_no_upper_bound() -> None:
"""A rule without end_date keeps firing arbitrarily far out — for
DAILY, WEEKLY, CUSTOM_DAYS, MONTHLY_DAY, MONTHLY_WEEKDAY, YEARLY."""
daily = ScheduleRule(rule_type=RuleType.DAILY,
start_date="2026-03-01")
assert occurs_on(daily, _d("2036-03-01")) is True
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03",
)
# 2076-03-03 is 50 years to the day after the anchor and a Tuesday.
assert occurs_on(weekly, _d("2076-03-03")) is True
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={4},
start_date="2026-03-06", # a Friday
)
assert occurs_on(custom, _d("2026-03-06")) is True
# The first Friday of March 2046 is Mar 2; Mar 6 is a Tuesday.
assert occurs_on(custom, _d("2046-03-01")) is False # not in set
assert occurs_on(custom, _d("2046-03-02")) is True
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
assert occurs_on(monthly_day, datetime.date(2036, 3, 15)) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, start_date="2026-03-04",
)
# First Wednesday of March 2036.
d = datetime.date(2036, 3, 1)
while d.weekday() != 2:
d += datetime.timedelta(days=1)
assert occurs_on(monthly_weekday, d) is True
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31",
)
assert occurs_on(yearly, datetime.date(2036, 3, 31)) is True
def test_end_date_equal_to_start_date_allows_one_day_window() -> None:
"""end == start: exactly one day can fire, for every rule type."""
daily = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-03-15",
end_date="2026-03-15",
)
assert occurs_on(daily, _d("2026-03-15")) is True
assert occurs_on(daily, _d("2026-03-14")) is False
assert occurs_on(daily, _d("2026-03-16")) is False
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1}, # 2026-03-10 is a Tue
start_date="2026-03-10", end_date="2026-03-10",
)
# The window is a SINGLE day (end == start, a Tuesday in-set):
# it fires, and the following Tuesday is out of window.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(weekly, _d("2026-03-10")) is True
assert occurs_on(weekly, _d("2026-03-17")) is False
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 4},
start_date="2026-03-11", end_date="2026-03-11",
)
# Mar 11 2026 is a Wednesday: fires. Next Wed (18) is out of window.
assert _d("2026-03-11").weekday() == 2
assert occurs_on(custom, _d("2026-03-11")) is True
assert occurs_on(custom, _d("2026-03-18")) is False
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(monthly_day, _d("2026-03-15")) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-10",
end_date="2026-03-10",
)
# Mar 10 2026 is the second Tuesday: the one-day window admits it.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(monthly_weekday, _d("2026-03-10")) is True
assert occurs_on(monthly_weekday, _d("2026-04-14")) is False
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(yearly, _d("2026-03-15")) is True
assert occurs_on(yearly, _d("2027-03-15")) is False
# ---------------------------------------------------------------------------
# end_date before start_date is rejected at construction, every type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
{"rule_type": RuleType.DAILY, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.WEEKLY, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.CUSTOM_DAYS, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_DAY, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_WEEKDAY, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.YEARLY, "month": 6, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
],
)
def test_end_before_start_rejected_for_every_type(kwargs) -> None:
with pytest.raises(RuleValidationError, match="on or after"):
ScheduleRule(**kwargs)
# ---------------------------------------------------------------------------
# occurrences_between respects the window for every type
# ---------------------------------------------------------------------------
def test_occurrences_between_respects_end_bound_all_types() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"]
)
result = occurrences_between(rule, "2026-01-01", "2026-12-31")
# Every result inside the queried range, none beyond June.
assert all(date <= "2026-06-30" for date in result), result
assert all(
date >= fixture["kwargs"]["start_date"] for date in result
), result
def test_occurrences_between_empty_when_range_before_start() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(**fixture["kwargs"])
assert occurrences_between(rule, "2026-01-01", "2026-02-28") == []
def test_occurrences_between_empty_when_range_after_end() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"],
)
result = occurrences_between(rule, "2026-10-01", "2026-12-31")
assert result == []
-444
View File
@@ -1,444 +0,0 @@
"""Consolidated recurrence suite over a fixed three-year calendar.
Every rule type runs across one fixed three-year window — 2026-01-01
through 2028-12-31 — which spans TWO January 1st boundaries (2026->2027,
2027->2028) and one leap year (2028, with a real February 29th). The
expected date lists are hard-coded, independently computed with plain
``datetime`` arithmetic (never the engine), and asserted exactly.
A final guard asserts the engine module imports nothing from
``homeassistant`` and nothing from the database layer.
"""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
ScheduleRule,
RuleType,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
#: The fixed consolidated window: three full years.
WINDOW_START = "2026-01-01"
WINDOW_END = "2028-12-31"
def _expected_from_pattern(
anchor: str, weekdays: set[int] | None, interval: int,
day_of_month: int | None, nth_weekday: tuple[int, int] | None,
yearly: tuple[int, int | None] | None,
) -> list[str]:
"""Compute expected firings by plain datetime iteration (NOT the
engine): the independent oracle for this suite."""
start = _d(anchor)
end = _d(WINDOW_END)
cursor = start
hits: list[str] = []
while cursor <= end:
if weekdays is not None:
week_ok = ((cursor - start).days // 7) % interval == 0
if week_ok and cursor.weekday() in weekdays:
hits.append(cursor.isoformat())
elif day_of_month is not None:
months_elapsed = (
(cursor.year - start.year) * 12
+ (cursor.month - start.month)
)
if months_elapsed % interval == 0:
month_end = (
datetime.date(cursor.year, cursor.month, 1)
if False
else _month_end(cursor.year, cursor.month)
)
if cursor.day == min(day_of_month, month_end):
hits.append(cursor.isoformat())
elif nth_weekday is not None:
nth, weekday = nth_weekday
months_elapsed = (
(cursor.year - start.year) * 12
+ (cursor.month - start.month)
)
if months_elapsed % interval == 0 and cursor.weekday() == weekday:
day = _nth_day(cursor.year, cursor.month, weekday, nth)
if day is not None and cursor.day == day:
hits.append(cursor.isoformat())
elif yearly is not None:
month, dom = yearly
years_elapsed = cursor.year - start.year
if years_elapsed % interval == 0 and cursor.month == month:
if dom is None:
if cursor.day == 1:
hits.append(cursor.isoformat())
else:
month_end = _month_end(cursor.year, cursor.month)
if cursor.day == min(dom, month_end):
hits.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return hits
def _month_end(year: int, month: int) -> int:
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + first_offset_calc(first, weekday) + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _first_offset_calc(first, weekday):
return (weekday - first.weekday()) % 7
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (max(nth, 0) - 1 if nth > 0 else 0) * 7
if nth == -1:
return _nth_day_back(year, month, weekday)
if day > _month_end(year, month):
return None
return day
def _first_offset_calc(first, weekday):
return (weekday - first.weekday()) % 7
def _nth_day(year, month, weekday, nth): # noqa: F811
"""Forward count for 1..5; back-count for -1."""
if nth == -1:
return _nth_day(year, month, weekday, -1) # pragma: no cover
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _nth_day(year, month, weekday, nth): # noqa: F811
if nth != -1:
return _forward_nth(year, month, weekday, nth)
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
def _forward_nth(year, month, weekday, nth):
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
# Consolidated rules: one per shape, anchored inside the window.
CONSOLIDATED_RULES = [
("daily", ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-01-01",
)),
("daily-x7", ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-05",
)),
("weekly-mwf", ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-01-05", # a Monday
)),
("custom-tts", ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
start_date="2026-01-03", # a Saturday
)),
("monthly-31st", ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)),
("monthly-2nd-wed", ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, start_date="2026-01-01",
)),
("yearly-leap", ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)),
("yearly-jun-15-x2", ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)),
]
# ---------------------------------------------------------------------------
# Exact expected lists over the full three-year window
# ---------------------------------------------------------------------------
def test_daily_exact_three_year_list() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY,
start_date="2026-01-01")
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independent oracle: plain datetime iteration over the whole window.
expected = []
cursor = _d("2026-01-01")
end = _d(WINDOW_END)
while cursor <= end:
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
# 365 + 365 + 366 = 1096 days: every single day fires.
assert len(result) == 1096
assert result[0] == "2026-01-01"
assert result[-1] == "2028-12-31"
assert "2028-02-29" in result
assert len(result) == len(set(result))
def test_daily_x7_exact_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-01"
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Offsets 0, 7, 14, ... <= 1095: 157 firings.
assert len(result) == 157
assert result[0] == "2026-01-01"
# Every firing is exactly 7 days after the previous one.
dates = [_d(date) for date in result]
gaps = {
(second - first).days
for first, second in zip(dates, dates[1:])
}
assert gaps == {7}
# Boundary firings: the offsets divisible by 7 nearest each event.
# Jan 1 2027 = offset 365 (skipped, 365 % 7 = 1); nearest firing:
anchor = datetime.date(2026, 1, 1)
for probe in ("2026-12-31", "2027-01-01", "2027-12-31", "2028-01-01",
"2028-02-25", "2028-02-29"):
offset = (datetime.date.fromisoformat(probe)
- datetime.date(2026, 1, 1)).days
expected_fire = offset % 7 == 0
fired = probe in result
assert fired is expected_fire, (
f"{probe}: offset {offset}, fired {fired}, "
f"expected {expected_fire}"
)
def test_weekly_mwf_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-01-05", # a Monday
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independently count Mon/Wed/Fri in 2026-2028.
expected = []
cursor = _d("2026-01-05")
end = _d(WINDOW_END)
while cursor <= end:
if cursor.weekday() in (0, 2, 4):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
# Three years of M/W/F: 156 weeks * 3 + 2 (2028 has a leap day but
# weekdays repeat weekly) -> verify the count matches plain math.
assert len(expected) == 468
def test_custom_tues_sats_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
start_date="2026-01-03", # a Saturday
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
expected = []
cursor = _d("2026-01-03")
end = _d(WINDOW_END)
while cursor <= end:
if cursor.weekday() in (2, 6):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
def test_monthly_31st_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independent oracle: for each month, min(31, month_end).
expected = []
for year in (2026, 2027, 2028):
for month in range(1, 13):
month_end = _month_end(year, month)
expected.append(
f"{year:04d}-{month:02d}-{min(31, month_end):02d}"
)
assert result == expected
assert len(result) == 36
assert "2026-02-28" in result
assert "2027-02-28" in result
assert "2028-02-29" in result
assert "2026-04-30" in result
assert "2028-04-30" in result
def test_monthly_2nd_wed_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, start_date="2026-01-01",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# 36 months, one firing each: the second Wednesday.
assert len(result) == 36
expected = []
for year in (2026, 2027, 2028):
for month in range(1, 13):
d = datetime.date(year, month, 1)
weds = []
while d.month == month and d.year == year:
if d.weekday() == 2:
weds.append(d.day)
d += datetime.timedelta(days=1)
expected.append(f"{year:04d}-{month:02d}-{weds[1]:02d}")
assert result == expected
def test_yearly_leap_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# 2026 clamp Feb 28, 2027 clamp Feb 28, 2028 leap Feb 29.
assert result == ["2026-02-28", "2027-02-28", "2028-02-29"]
def test_yearly_jun_15_interval_2_exact_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# y0 2026, y2 2028 (y3 2029 excluded by the 3-year window).
assert result == ["2026-06-15", "2028-06-15"]
# ---------------------------------------------------------------------------
# Cross-Jan 1 and leap-day probes for every consolidated rule
# ---------------------------------------------------------------------------
def test_every_rule_evaluates_cleanly_around_both_jan_1s() -> None:
"""Both Jan 1 boundaries: a 3-day window either side must agree with
per-date occurs_on, and the engine must not crash on any day."""
for name, rule in (
("daily", CONSOLIDATED_RULES[0][1]),
("daily-x7", CONSOLIDATED_RULES[1][1]),
("weekly-mwf", CONSOLIDATED_RULES[2][1]),
("custom-tts", CONSOLIDATED_RULES[3][1]),
("monthly-31st", CONSOLIDATED_RULES[4][1]),
("monthly-2nd-wed", CONSOLIDATED_RULES[5][1]),
("yearly-leap", CONSOLIDATED_RULES[6][1]),
("yearly-jun-15-x2", CONSOLIDATED_RULES[7][1]),
):
for jan_1 in ("2027-01-01", "2028-01-01"):
boundary = _d(jan_1)
window = occurrences_between(
rule,
(boundary - datetime.timedelta(days=3)).isoformat(),
(boundary + datetime.timedelta(days=3)).isoformat(),
)
# Ordered and duplicate-free.
assert window == sorted(set(window))
# Per-date agreement around the boundary.
for offset in range(-3, 4):
date = boundary + datetime.timedelta(days=offset)
fired = occurs_on(rule, date)
in_list = date.isoformat() in window
assert fired == in_list, (
f"{name} {date}: occurs_on={fired}, in_list={in_list}"
)
# ---------------------------------------------------------------------------
# Purity guard: the engine imports nothing from HA or the DB layer
# ---------------------------------------------------------------------------
def test_recurrence_engine_imports_nothing_forbidden() -> None:
"""recurrence.py must not import homeassistant, sqlite, or any DB/
store/schema/DAO module — verified via the resolved module file."""
import ast
import importlib.util
from pathlib import Path
module_file = Path(
importlib.util.find_spec(
"custom_components.nestquest.recurrence"
).origin
)
tree = ast.parse(module_file.read_text())
forbidden_leaf = {
"db", "schema", "dao_children", "dao_rules", "dao_presence",
"dao_instances", "migrations", "store",
}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
lowered = alias.name.lower()
leaf = lowered.split(".")[-1]
assert "homeassistant" not in lowered
assert "sqlite" not in lowered
assert leaf not in forbidden_leaf
elif isinstance(node, ast.ImportFrom):
module = (node.module or "").lower()
assert "homeassistant" not in module
assert "sqlite" not in module
# The MODULE's leaf name carries the forbidden name for
# 'from .store import Store' style imports.
module_leaf = module.split(".")[-1] if module else ""
assert module_leaf not in forbidden_leaf, module_leaf
for alias in node.names:
leaf = alias.name.split(".")[-1].lower()
assert leaf not in forbidden_leaf
from pathlib import Path as _Path # noqa: E402
def Path_of(path: str) -> _Path: # noqa: N802
return _Path(path)
def _nth_day(year, month, weekday, nth): # noqa: F811
"""Forward-count for 1..5; back-count for -1 (canonical helper)."""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
return last.day - ((last.weekday() - weekday) % 7)
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
-289
View File
@@ -1,289 +0,0 @@
"""Tests for the daily recurrence evaluation (occurs_on/occurrences_between)."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def test_daily_fires_every_day_from_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert occurs_on(rule, _d("2026-09-01")) is True
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-09-30")) is True
def test_daily_never_fires_before_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert occurs_on(rule, _d("2026-08-31")) is False
def test_daily_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-10",
)
assert occurs_on(rule, _d("2026-09-10")) is True
assert occurs_on(rule, _d("2026-09-11")) is False
def test_daily_interval_2_fires_every_second_day() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
# Odd offsets fire (0, 2, 4...); odd offsets don't.
assert occurs_on(rule, _d("2026-09-01")) is True # offset 0
assert occurs_on(rule, _d("2026-09-02")) is False # offset 1
assert occurs_on(rule, _d("2026-09-03")) is True # offset 2
assert occurs_on(rule, _d("2026-09-04")) is False # offset 3
assert occurs_on(rule, _d("2026-09-05")) is True # offset 4
def test_daily_interval_3_fires_every_third_day() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
)
assert occurs_on(rule, _d("2026-09-01")) is True # 0
assert occurs_on(rule, _d("2026-09-02")) is False # 1
assert occurs_on(rule, _d("2026-09-03")) is False # 2
assert occurs_on(rule, _d("2026-09-04")) is True # 3
assert occurs_on(rule, _d("2026-09-07")) is True # 6
def test_hand_checked_30_day_sequence_interval_1() -> None:
"""The done-condition's hand-checked sequence: 30 days, all fire."""
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
expected = [
(2026, 9, day) for day in range(1, 31)
]
for (year, month, day) in expected:
assert occurs_on(rule, datetime.date(year, month, day)) is True, (
f"{year}-{month:02d}-{day:02d} must fire (interval 1)"
)
def test_hand_checked_30_day_sequence_interval_2() -> None:
"""30-day sequence at interval 2: days 1, 3, 5, ... (odd days)."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
for day in range(1, 31):
fired = occurs_on(rule, datetime.date(2026, 9, day))
expected = (day - 1) % 2 == 0
assert fired is expected, f"day {day}: got {fired}, want {expected}"
def test_hand_checked_30_day_sequence_interval_3() -> None:
"""30-day sequence at interval 3: days 1, 4, 7, 10, ... 28."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
)
fired_days = [
day
for day in range(1, 31)
if occurs_on(rule, datetime.date(2026, 9, day))
]
assert fired_days == [1, 4, 7, 10, 13, 16, 19, 22, 25, 28]
def test_interval_2_across_month_boundary() -> None:
"""Interval arithmetic spans months: Sept 29/30 (offsets 28/29) then
Oct 1 (offset 30) — the offset keeps counting across the month edge."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
assert occurs_on(rule, _d("2026-09-29")) is True # offset 28
assert occurs_on(rule, _d("2026-09-30")) is False # offset 29
assert occurs_on(rule, _d("2026-10-01")) is True # offset 30
assert occurs_on(rule, _d("2026-10-02")) is False # offset 31
def test_interval_2_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
)
assert occurs_on(rule, _d("2026-12-30")) is True # 0
assert occurs_on(rule, _d("2026-12-31")) is False # 1
assert occurs_on(rule, _d("2027-01-01")) is True # 2
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_occurrences_between_daily_interval_1() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
result = occurrences_between(rule, "2026-09-05", "2026-09-08")
assert result == [
"2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08",
]
def test_occurrences_between_daily_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
result = occurrences_between(rule, "2026-09-01", "2026-09-10")
assert result == [
"2026-09-01", "2026-09-03", "2026-09-05", "2026-09-07", "2026-09-09",
]
def test_occurrences_between_clamps_to_rule_start() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-05")
result = occurrences_between(rule, "2026-09-01", "2026-09-08")
assert result == ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"]
def test_occurrences_between_clamps_to_rule_end() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-06",
)
result = occurrences_between(rule, "2026-09-01", "2026-09-20")
assert result == [
f"2026-09-{day:02d}" for day in range(1, 7)
]
def test_occurrences_between_disjoint_range_is_empty() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-09-01",
end_date="2026-09-15",
)
# The rule ends Sept 15: October is entirely outside its window.
assert occurrences_between(rule, "2026-10-01", "2026-10-05") == []
assert occurrences_between(rule, "2026-08-01", "2026-08-05") == []
def test_occurrences_between_inclusive_bounds() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
)
# Both boundary dates fire at interval 2 (offsets 0 and 6).
result = occurrences_between(rule, "2026-09-01", "2026-09-07")
assert result[0] == "2026-09-01"
assert result[-1] == "2026-09-07"
def test_occurrences_between_rejects_inverted_range() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-09-10", "2026-09-05")
def test_occurrences_between_rejects_malformed_dates() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
occurrences_between(rule, "oops", "2026-09-05")
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
occurrences_between(rule, "2026-09-05", "2026-9-5")
def test_occurrences_between_leap_day_fires() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-02-27")
# 2028 is a leap year: Feb 29 exists and is 2 days after Feb 27.
result = occurrences_between(rule, "2028-02-28", "2028-03-01")
assert result == ["2028-02-28", "2028-02-29", "2028-03-01"]
def test_occurrences_between_year_boundary() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-12-30")
result = occurrences_between(rule, "2026-12-30", "2027-01-02")
assert result == [
"2026-12-30", "2026-12-31", "2027-01-01", "2027-01-02",
]
# ---------------------------------------------------------------------------
# Purity: the engine raises on unknown shapes rather than guessing
# ---------------------------------------------------------------------------
def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
"""All six model shapes are implemented; a shape added to the model
without an engine arm must raise a loud NotImplementedError rather
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
ForgedType = enum_module.Enum(
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
)
forged_rule = ScheduleRule.__new__(ScheduleRule)
object.__setattr__(forged_rule, "rule_type", ForgedType.UNKNOWN_SHAPE)
object.__setattr__(forged_rule, "interval", 1)
object.__setattr__(forged_rule, "weekday_set", None)
object.__setattr__(forged_rule, "day_of_month", None)
object.__setattr__(forged_rule, "nth_weekday", None)
object.__setattr__(forged_rule, "nth_weekday_weekday", None)
object.__setattr__(forged_rule, "month", None)
object.__setattr__(forged_rule, "start_date", "2026-09-01")
object.__setattr__(forged_rule, "end_date", None)
for day in ("2026-08-31", "2026-09-15", "2026-12-31"):
with pytest.raises(NotImplementedError):
occurs_on(forged_rule, _d(day))
with pytest.raises(NotImplementedError):
occurrences_between(forged_rule, "2026-08-01", "2026-12-31")
def test_interval_7_preserves_weekday_alignment_across_months() -> None:
"""Weekly-by-interval semantics: every firing lands on the SAME
weekday as start_date, no matter how many month edges intervene."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01"
)
# 2026-09-01 is a Tuesday; every firing must be a Tuesday too.
assert _d("2026-09-01").weekday() == 1
for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"):
assert occurs_on(rule, _d(day)) is True, day
assert _d(day).weekday() == 1, f"{day} must be a Tuesday"
# The day after a firing is never a firing.
assert occurs_on(rule, _d("2026-10-07")) is False
def test_interval_2_across_leap_day() -> None:
"""Interval > 1 arithmetic across a leap day: the extra day shifts
subsequent offsets by one, and the mod arithmetic reflects it."""
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
)
assert occurs_on(rule, _d("2028-02-27")) is True # 0
assert occurs_on(rule, _d("2028-02-28")) is False # 1
assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day)
assert occurs_on(rule, _d("2028-03-01")) is False # 3
assert occurs_on(rule, _d("2028-03-02")) is True # 4
def test_occurrences_between_leap_day_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
)
result = occurrences_between(rule, "2028-02-27", "2028-03-03")
assert result == ["2028-02-27", "2028-02-29", "2028-03-02"]
def test_occurrences_between_year_boundary_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
)
result = occurrences_between(rule, "2026-12-30", "2027-01-04")
# Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are
# Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets:
assert result == ["2026-12-30", "2027-01-01", "2027-01-03"]
-485
View File
@@ -1,485 +0,0 @@
"""Tests for recurrence.py: the ScheduleRule model and serialization."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
)
def _rule(**kwargs) -> ScheduleRule:
return ScheduleRule(**kwargs)
# ---------------------------------------------------------------------------
# Valid construction for every rule type
# ---------------------------------------------------------------------------
def test_daily_rule_constructs_with_defaults() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert rule.rule_type is RuleType.DAILY
assert rule.interval == 1
assert rule.weekday_set is None
assert rule.day_of_month is None
assert rule.nth_weekday is None
assert rule.month is None
assert rule.end_date is None
def test_string_rule_type_coerces() -> None:
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
assert rule.rule_type is RuleType.WEEKLY
def test_weekly_rule_requires_and_freezes_weekday_set() -> None:
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01")
assert rule.weekday_set == frozenset({0, 2, 4})
# A mutable set passed in is frozen on construction.
source = {1, 3}
rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01")
source.add(5)
assert rule2.weekday_set == frozenset({1, 3})
def test_monthly_day_rule_fields() -> None:
rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01")
assert rule.day_of_month == 15
assert rule.nth_weekday is None
def test_monthly_weekday_rule_fields() -> None:
rule = _rule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=2,
nth_weekday_weekday=1,
start_date="2026-09-01",
)
assert rule.nth_weekday == 2
assert rule.nth_weekday_weekday == 1
def test_monthly_weekday_last_friday_allowed() -> None:
rule = _rule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=-1,
nth_weekday_weekday=4,
start_date="2026-09-01",
)
assert rule.nth_weekday == -1
def test_yearly_rule_fields() -> None:
rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01")
assert rule.month == 6
def test_custom_days_rule_fields() -> None:
rule = _rule(
rule_type=RuleType.CUSTOM_DAYS,
weekday_set={1, 3, 5},
interval=2,
start_date="2026-09-01",
)
assert rule.weekday_set == frozenset({1, 3, 5})
assert rule.interval == 2
def test_end_date_round_trips(tmp_path) -> None:
rule = _rule(
rule_type=RuleType.DAILY,
start_date="2026-09-01",
end_date="2026-12-31",
)
assert rule.end_date == "2026-12-31"
# ---------------------------------------------------------------------------
# Invalid combinations are rejected at construction
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
# weekly without weekday_set
{"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"},
# daily carrying a weekday_set
{"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"},
# monthly_day without day_of_month
{"rule_type": "monthly_day", "start_date": "2026-09-01"},
# monthly_day out of range
{"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"},
# monthly_day carrying weekday_set
{"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0},
"start_date": "2026-09-01"},
# yearly without month
{"rule_type": "yearly", "start_date": "2026-09-01"},
# yearly out of range
{"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"},
# unknown type
{"rule_type": "bogus", "start_date": "2026-09-01"},
# interval zero
{"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"},
# interval non-integer
{"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"},
# end before start
{"rule_type": "daily", "start_date": "2026-09-01",
"end_date": "2026-08-31"},
# malformed dates
{"rule_type": "daily", "start_date": "not-a-date"},
{"rule_type": "daily", "start_date": "2026-9-1"},
# weekly weekday out of range
{"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"},
# weekly empty set
{"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"},
# custom_days carrying month-specific fields
{"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5,
"start_date": "2026-09-01"},
{"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
{"rule_type": "custom_days", "weekday_set": {0}, "month": 3,
"start_date": "2026-09-01"},
# monthly_weekday position invalid
{"rule_type": "monthly_weekday", "nth_weekday": 0,
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 6,
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 2,
"nth_weekday_weekday": None, "start_date": "2026-09-01"},
{"rule_type": "monthly_weekday", "nth_weekday": 2,
"nth_weekday_weekday": 7, "start_date": "2026-09-01"},
# monthly_day carrying nth fields
{"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
],
)
def test_invalid_combinations_raise(kwargs) -> None:
with pytest.raises(RuleValidationError):
_rule(**kwargs)
# ---------------------------------------------------------------------------
# Immutability
# ---------------------------------------------------------------------------
def test_rule_is_immutable() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
with pytest.raises(AttributeError):
rule.interval = 2
with pytest.raises(AttributeError):
del rule.interval
# ---------------------------------------------------------------------------
# Serialization round-trip for every rule type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"rule",
[
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01",
end_date="2026-12-31"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=4, interval=2,
start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None,
interval=2, start_date="2026-09-01"),
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
interval=2, start_date="2026-09-01"),
],
)
def test_to_dict_from_dict_round_trips_losslessly(rule) -> None:
rebuilt = ScheduleRule.from_dict(rule.to_dict())
assert rebuilt == rule
# Round-tripping twice is stable.
assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule
def test_from_dict_rejects_unknown_fields() -> None:
with pytest.raises(RuleValidationError, match="unknown rule fields"):
ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1})
def test_from_dict_rejects_missing_rule_type() -> None:
with pytest.raises(RuleValidationError, match="missing required"):
ScheduleRule.from_dict({"interval": 1})
def test_from_dict_rejects_non_dict() -> None:
with pytest.raises(RuleValidationError):
ScheduleRule.from_dict("daily")
def test_from_dict_defaults_start_date_when_absent() -> None:
rule = ScheduleRule.from_dict({"rule_type": "daily"})
assert rule.start_date == "1970-01-01"
def test_from_dict_accepts_list_weekday_set() -> None:
rule = ScheduleRule.from_dict(
{"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]}
)
assert rule.weekday_set == frozenset({0, 2, 4})
# ---------------------------------------------------------------------------
# Equality and hashing
# ---------------------------------------------------------------------------
def test_equal_rules_compare_equal_and_hash_equal() -> None:
a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01")
assert a == b
assert hash(a) == hash(b)
def test_different_rules_compare_unequal() -> None:
a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01")
assert a != b
def test_rule_not_equal_to_other_types() -> None:
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
assert rule != "daily"
assert (rule == 42) is False
# ---------------------------------------------------------------------------
# Purity guards: no HA imports, no database access
# ---------------------------------------------------------------------------
def test_recurrence_module_imports_only_stdlib() -> None:
"""The model must carry no Home Assistant and no DB imports."""
from pathlib import Path
source = Path(
__import__("custom_components.nestquest.recurrence",
fromlist=["__file__"]).__file__
).read_text()
offenders = _scan_forbidden_imports(source)
assert offenders == [], (
f"recurrence.py must not hold forbidden imports: {offenders}"
)
def _scan_forbidden_imports(source: str) -> list[str]:
"""The ACTUAL purity-guard logic, shared by the model scan and the
probe tests below: returns human-readable offenders (empty = clean).
Forbidden, at any relative level, for both Import and ImportFrom:
- modules containing 'homeassistant' or 'sqlite'
- modules whose LEAF name is a DB/store/schema/DAO module
- aliases importing a DB/store/schema/DAO module by leaf name
('from . import db', 'from custom_components.nestquest import db')
"""
import ast
forbidden_targets = {
"db", "schema", "dao_children", "dao_rules", "dao_presence",
"dao_instances", "migrations", "store",
}
offenders: list[str] = []
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
lowered = alias.name.lower()
leaf = lowered.split(".")[-1]
if (
"homeassistant" in lowered
or "sqlite" in lowered
or leaf in forbidden_targets
):
offenders.append(f"import {alias.name}")
elif isinstance(node, ast.ImportFrom):
module = (node.module or "").lower()
module_leaf = module.split(".")[-1] if module else ""
if (
"homeassistant" in module
or "sqlite" in module
or (module and module_leaf in forbidden_targets)
or (node.level and module_leaf in forbidden_targets)
):
offenders.append(
f"from {node.module!r} import ... (level={node.level})"
)
for alias in node.names:
alias_leaf = alias.name.split(".")[-1].lower()
if alias_leaf in forbidden_targets:
offenders.append(
f"from {node.module!r} import {alias.name} "
f"(level={node.level})"
)
return offenders
def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
"""The shared scan function catches every forbidden import shape,
including level-0 absolute package DB imports and 'from . import db'.
"""
probes = [
"from homeassistant.core import HomeAssistant",
"import sqlite3",
"from .db import NestQuestDatabase",
"from custom_components.nestquest.db import NestQuestDatabase",
"from custom_components.nestquest import db",
"import custom_components.nestquest.dao_rules",
"from . import db",
"from . import schema",
"from custom_components.nestquest.migrations import apply_migrations",
]
for probe in probes:
offenders = _scan_forbidden_imports(probe)
assert offenders, f"probe must be caught: {probe!r}"
def test_non_string_rule_type_rejected() -> None:
"""Only RuleType members and their string values construct."""
for bad in (123, True, None, 3.5, b"daily"):
with pytest.raises(RuleValidationError):
ScheduleRule(rule_type=bad, start_date="2026-09-01")
def test_weekly_rejects_month_and_nth_fields() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, day_of_month=5,
start_date="2026-09-01",
)
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, month=3,
start_date="2026-09-01",
)
def test_daily_rejects_dangling_nth_weekday_weekday() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.DAILY, nth_weekday_weekday=0,
start_date="2026-09-01",
)
def test_monthly_day_rejects_month() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=5, month=3,
start_date="2026-09-01",
)
def test_yearly_rejects_day_and_nth_fields() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, nth_weekday=1,
nth_weekday_weekday=0, start_date="2026-09-01",
)
# YEARLY MAY carry an optional day_of_month (Feb 29 rules need it):
rule = ScheduleRule(rule_type=RuleType.YEARLY, month=2,
day_of_month=29, start_date="2026-09-01")
assert rule.day_of_month == 29
# ...but it is still validated when present.
with pytest.raises(RuleValidationError, match="day_of_month"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=32,
start_date="2026-09-01",
)
def test_storage_value_mapping() -> None:
"""The enum exposes the schema's CHECK values via storage_value."""
assert RuleType.DAILY.storage_value == "daily"
assert RuleType.WEEKLY.storage_value == "weekly"
assert RuleType.MONTHLY_DAY.storage_value == "monthly"
assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly"
assert RuleType.YEARLY.storage_value == "yearly"
assert RuleType.CUSTOM_DAYS.storage_value == "custom"
def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None:
"""[0, False] must NOT collapse False into 0 via set() coercion."""
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule.from_dict(
{"rule_type": "weekly", "weekday_set": [0, False]}
)
def test_direct_set_with_int_subclass_entry_rejected() -> None:
"""A hashable-but-non-plain int subclass is still not a plain int."""
class IntChild(int):
pass
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={IntChild(1)},
start_date="2026-09-01",
)
def test_from_dict_with_int_subclass_entry_rejected() -> None:
class IntChild(int):
pass
with pytest.raises(RuleValidationError, match="plain integers"):
ScheduleRule.from_dict(
{"rule_type": "weekly", "weekday_set": [IntChild(1)]}
)
def test_from_dict_unknown_key_types_never_leak_typeerror() -> None:
"""Mixed unhashable unknown keys must surface as
RuleValidationError, not a raw TypeError from sorting."""
with pytest.raises(RuleValidationError, match="unknown rule fields"):
ScheduleRule.from_dict({"rule_type": "daily", 42: "value"})
def test_storage_aliases_and_whitespace_are_not_model_names() -> None:
"""The model boundary is exact names only: storage strings, padded
and case variants are all rejected."""
for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"):
with pytest.raises(RuleValidationError):
ScheduleRule(rule_type=bad, start_date="2026-09-01")
def test_unhashable_int_subclass_interval_rejected() -> None:
class UnhashableInt(int):
__hash__ = None
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.DAILY,
interval=UnhashableInt(1),
start_date="2026-09-01",
)
def test_rule_type_is_a_dataclass() -> None:
import dataclasses
assert dataclasses.is_dataclass(ScheduleRule)
-301
View File
@@ -1,301 +0,0 @@
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_month_end,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings
# ---------------------------------------------------------------------------
def test_monthly_day_15_fires_on_the_15th() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
for month in range(1, 13):
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
def test_monthly_day_never_fires_on_other_days() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
start_date="2026-01-10",
)
# Every non-10th day of a short month must be False.
for day in range(1, 30):
if day != 10:
assert occurs_on(
rule, datetime.date(2026, 1, day)
) is False, f"Jan {day}"
def test_monthly_day_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
# Feb 15 precedes the start: no firing even though the day matches.
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
def test_monthly_day_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15", end_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
# ---------------------------------------------------------------------------
# Month-end clamp policy
# ---------------------------------------------------------------------------
def test_month_end_helper_is_leap_aware() -> None:
assert _month_end(2026, 1) == 31
assert _month_end(2026, 4) == 30
assert _month_end(2026, 2) == 28 # non-leap
assert _month_end(2028, 2) == 29 # leap
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
assert _month_end(2026, 12) == 31
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Months with a 31st: fire on the 31st.
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
(10, 31), (12, 31)):
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
f"2026-{month:02d}-{day:02d}"
)
# Months WITHOUT a 31st: fire on the month's last day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
# The day BEFORE the clamp is not a firing.
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
def test_day_30_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2026-01-30",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# Months with a 30th: nominal day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2026-01-29",
)
# Non-leap 2027: Feb 28 is the clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
# Leap 2028: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_day_30_feb_leap_year_clamps_to_29() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2027-01-30",
)
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# ---------------------------------------------------------------------------
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
# ---------------------------------------------------------------------------
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
"""Every (month, day) in ``rule``'s year the engine says fires."""
hits = []
for month in range(1, 13):
for day in range(1, _month_end(year, month) + 1):
if occurs_on(rule, datetime.date(year, month, day)):
hits.append((month, day))
return hits
def test_full_year_day_31_sweep_2026() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Exactly one firing per month, twelve total.
hits = _sweep_monthly(rule, 2026)
assert len(hits) == 12
by_month = dict(hits)
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
assert by_month[2] == 28 # 2026 Feb is non-leap
assert by_month[4] == 30
# Each month has exactly one firing.
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_31_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2028-01-31",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap February clamps to the 29th
assert by_month[4] == 30
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_30_sweep_2028() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2028-01-30",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb clamps to 29
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2027_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2027-01-29",
)
hits = _sweep_monthly(rule, 2027)
by_month = dict(hits)
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2028-01-29",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
months = [month for month, _ in hits]
assert months == list(range(1, 13))
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_interval_2_every_second_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-01-15",
)
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
"""Interval counts nominal months from the anchor: Jan 31 anchor,
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
start_date="2026-01-31",
)
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-11-15",
)
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
def test_monthly_day_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
assert result == [
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
"2026-05-31", "2026-06-30",
]
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-15", "2026-01-15")
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
"""The clamp must not double-fire: only one day per month matches."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
hits = _sweep_monthly(rule, 2026)
months = [month for month, _ in hits]
assert months == sorted(months)
assert len(months) == len(set(months)) == 12
-324
View File
@@ -1,324 +0,0 @@
"""Tests for MONTHLY_WEEKDAY (nth-weekday-of-month) recurrence."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_nth_weekday_of_month,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# The helper: nth occurrence day-of-month
# ---------------------------------------------------------------------------
def test_nth_weekday_helper_first_second_third() -> None:
"""Independently verified: Mar 1 2026 is a Sunday; Wednesdays are
4, 11, 18, 25 (four — no 5th)."""
assert _nth_weekday_of_month(2026, 3, 2, 1) == 4
assert _nth_weekday_of_month(2026, 3, 2, 2) == 11
assert _nth_weekday_of_month(2026, 3, 2, 3) == 18
assert _nth_weekday_of_month(2026, 3, 2, 4) == 25
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
"""Independently verified: April 2026 Fridays are 3, 10, 17, 24
(four) — no 5th occurrence."""
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
def test_nth_weekday_helper_last() -> None:
"""Independently verified: March 2026 Fridays are 6, 13, 20, 27;
February 2028 Saturdays are 5, 12, 19, 26 (Feb 29 2028 is a
Tuesday, so the last SATURDAY is the 26th, not the last day)."""
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
# February 2026: 28 days, last day IS a Saturday.
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
# February 2028: the last Saturday is the 26th (Feb 29 is a Tue).
assert _nth_weekday_of_month(2028, 2, 5, -1) == 26
def test_nth_weekday_helper_last_when_month_has_five() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the last is the 30th, and the 5th is the same day."""
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
assert _nth_weekday_of_month(2026, 10, 4, 5) == 30
def test_helper_zero_and_out_of_range_nth() -> None:
"""The model rejects n 0 before the helper sees it; -1/1..5 only."""
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=0,
nth_weekday_weekday=1, start_date="2026-09-01",
)
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=6,
nth_weekday_weekday=1, start_date="2026-09-01",
)
# ---------------------------------------------------------------------------
# occurs_on: first / second / third / fourth / last across 12 months
# ---------------------------------------------------------------------------
def test_first_tuesday_of_twelve_consecutive_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, # first Wednesday
start_date="2026-01-01",
)
first_wednesdays = [
(1, 7), (2, 4), (3, 4), (4, 1), (5, 6), (6, 3),
(7, 1), (8, 5), (9, 2), (10, 7), (11, 4), (12, 2),
]
for month, day in first_wednesdays:
date = datetime.date(2026, month, day)
assert date.weekday() == 2
assert occurs_on(rule, date) is True, date
# Neighbors do not fire.
assert occurs_on(rule, date - datetime.timedelta(days=7)) is False
assert occurs_on(rule, date + datetime.timedelta(days=7)) is False
def test_second_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified second Wednesdays of 2026 (Jan 1 is a
Thursday; Wednesdays per month from the calendar): the SECOND one
fires; the FIRST one does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
# (month, first_wed, second_wed) from plain datetime arithmetic:
verified = [
(1, 7, 14), (2, 4, 11), (3, 4, 11), (4, 1, 8),
(5, 6, 13), (6, 3, 10), (7, 1, 8), (8, 5, 12),
(9, 2, 9), (10, 7, 14), (11, 4, 11), (12, 2, 9),
]
for month, first_wed, second_wed in verified:
assert _d(f"2026-{month:02d}-{first_wed:02d}").weekday() == 2
assert _d(f"2026-{month:02d}-{second_wed:02d}").weekday() == 2
assert occurs_on(
rule, datetime.date(2026, month, second_wed)
) is True, f"2026-{month:02d}-{second_wed:02d}"
assert occurs_on(
rule, datetime.date(2026, month, first_wed)
) is False, f"2026-{month:02d}-{first_wed:02d}"
def test_third_friday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Fridays; the THIRD fires, the SECOND
does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
nth_weekday_weekday=4,
start_date="2026-01-01",
)
verified = [
# (month, first_fri, second_fri, third_fri)
(1, 2, 9, 16), (2, 6, 13, 20), (3, 6, 13, 20),
(4, 3, 10, 17), (5, 1, 8, 15), (6, 5, 12, 19),
(7, 3, 10, 17), (8, 7, 14, 21), (9, 4, 11, 18),
(10, 2, 9, 16), (11, 6, 13, 20), (12, 4, 11, 18),
]
for month, first_fri, second_fri, third_fri in verified:
third = _d(f"2026-{month:02d}-{third_fri:02d}")
assert third.weekday() == 4
assert occurs_on(rule, third) is True, third
second = _d(f"2026-{month:02d}-{second_fri:02d}")
assert occurs_on(rule, second) is False, second
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Wednesdays; the FOURTH fires, the
THIRD does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
verified = [
# (month, third_wed, fourth_wed)
(1, 21, 28), (2, 18, 25), (3, 18, 25), (4, 15, 22),
(5, 20, 27), (6, 17, 24), (7, 15, 22), (8, 19, 26),
(9, 16, 23), (10, 21, 28), (11, 18, 25), (12, 16, 23),
]
for month, third_wed, fourth_wed in verified:
fourth = _d(f"2026-{month:02d}-{fourth_wed:02d}")
assert fourth.weekday() == 2
assert occurs_on(rule, fourth) is True, fourth
third = _d(f"2026-{month:02d}-{third_wed:02d}")
assert not occurs_on(rule, third), third
def test_last_monday_of_twelve_consecutive_months() -> None:
"""Independently verified last Mondays of 2026; each fires, and the
PREVIOUS Monday of the same month does not (it is nth-from-last
minus one, i.e. the second-to-last)."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=0,
start_date="2026-01-01",
)
verified = [
# (month, last_monday)
(1, 26), (2, 23), (3, 30), (4, 27), (5, 25), (6, 29),
(7, 27), (8, 31), (9, 28), (10, 26), (11, 30), (12, 28),
]
for month, last_monday in verified:
date = _d(f"2026-{month:02d}-{last_monday:02d}")
assert date.weekday() == 0
assert occurs_on(rule, date) is True, date
# The Monday a week earlier is NOT the last: must not fire.
week_before = date - datetime.timedelta(days=7)
if week_before.month == month:
assert occurs_on(rule, week_before) is False, week_before
# ---------------------------------------------------------------------------
# The five-occurrence skip: a 5th on a four-occurrence month
# ---------------------------------------------------------------------------
def test_fifth_weekday_skips_four_occurrence_months() -> None:
"""Independently verified: 2026 months with FIVE Mondays are March,
June, August, November — every other month (four Mondays) must fire
on no day at all, and each hit must be the month's FINAL Monday."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=0, # fifth Monday
start_date="2026-01-01",
)
five_monday_months = {3: 30, 6: 29, 8: 31, 11: 30}
hit_months = set()
for month in range(1, 13):
if month in five_monday_months:
day = five_monday_months[month]
hit = _d(f"2026-{month:02d}-{day:02d}")
assert hit.weekday() == 0
assert occurs_on(rule, hit) is True, hit
# It is the FINAL Monday of the month: sweep EVERY remaining
# calendar day after the hit — none may fire.
for later in range(day + 1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, later)
) is False, f"2026-{month:02d}-{later:02d}"
# The Monday a week earlier is not the fifth.
assert occurs_on(
rule, datetime.date(2026, month, day - 7)
) is False
hit_months.add(month)
else:
for day in range(1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, day)
) is False, f"2026-{month:02d}-{day:02d}"
assert hit_months == {3, 6, 8, 11}, hit_months
def _month_end_days(year: int, month: int) -> int:
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def test_fifth_weekday_fires_in_five_occurrence_months() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the 5th (and last) is the 30th; April 2026 has four Fridays
(3, 10, 17, 24) so no day in April fires."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=4, # Fridays
start_date="2026-01-01",
)
# October: the 30th fires, is a Friday, and no Monday-later day
# (the 31st) is a Friday: the 30th is the FINAL Friday.
oct_30 = datetime.date(2026, 10, 30)
assert oct_30.weekday() == 4
assert occurs_on(rule, oct_30) is True
assert occurs_on(rule, datetime.date(2026, 10, 31)) is False
# April: no Friday-5th, so no day fires.
for day in range(1, _month_end_days(2026, 4) + 1):
assert occurs_on(rule, datetime.date(2026, 4, day)) is False, (
f"2026-04-{day:02d}"
)
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_weekday_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, interval=2, start_date="2026-01-01",
)
# Jan (m0) fires on the 2nd Tuesday; Feb (m1) skipped; Mar (m2) fires.
jan_second = _nth_weekday_of_month(2026, 1, 2, 2)
assert occurs_on(rule, datetime.date(2026, 1, jan_second)) is True
feb_second = _nth_weekday_of_month(2026, 2, 2, 2)
assert occurs_on(rule, datetime.date(2026, 2, feb_second)) is False
mar_second = _nth_weekday_of_month(2026, 3, 2, 2)
assert occurs_on(rule, datetime.date(2026, 3, mar_second)) is True
def test_monthly_weekday_interval_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, interval=3, start_date="2026-11-01",
)
# Nov (m0) fires; Dec (m1), Jan (m2) skip; Feb (m3) fires.
nov_first = _nth_weekday_of_month(2026, 11, 2, 1)
assert occurs_on(rule, datetime.date(2026, 11, nov_first)) is True
dec_first = _nth_weekday_of_month(2026, 12, 2, 1)
assert occurs_on(rule, datetime.date(2026, 12, dec_first)) is False
jan_first = _nth_weekday_of_month(2027, 1, 2, 1)
assert occurs_on(rule, datetime.date(2027, 1, jan_first)) is False
feb_first = _nth_weekday_of_month(2027, 2, 2, 1)
assert occurs_on(rule, datetime.date(2027, 2, feb_first)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_monthly_weekday_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=4, # second Friday
start_date="2026-01-01", end_date="2026-04-30",
)
result = occurrences_between(rule, "2026-01-01", "2026-04-30")
# Independently verified second Fridays: Jan 9 (Jan 1 Thu), Feb 13
# (Feb 1 Sun), Mar 13 (Mar 1 Sun), Apr 10 (Apr 1 Wed).
assert result == [
"2026-01-09", "2026-02-13", "2026-03-13", "2026-04-10",
]
def test_monthly_weekday_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=1, start_date="2026-01-01",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-01", "2026-01-01")
-241
View File
@@ -1,241 +0,0 @@
"""Consistency property tests for occurrences_between vs occurs_on.
The done-condition: occurrences_between returns an ordered, duplicate-
free list of dates that agrees EXACTLY with calling occurs_on on every
date in the range. This module proves that agreement two ways:
1. An exhaustive sweep over a fixed grid of rules (every rule type ×
several intervals × several windows) cross-checked date by date.
2. A randomized property test (seeded, deterministic) over randomly
generated rules and ranges asserting the two functions never
disagree. Seeds are fixed so failures are reproducible.
"""
from __future__ import annotations
import datetime
import random
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def _check_consistency(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Cross-check occurrences_between against per-date occurs_on.
Returns the occurrences_between result after asserting, for every
date in [start, end]: occurs_on(date) is True iff the date appears
in the result. Also asserts the list is ordered and duplicate-free.
"""
result = occurrences_between(rule, start, end)
# Ordered and duplicate-free.
assert result == sorted(set(result)), (
f"occurrences_between must be ordered, duplicate-free: {result}"
)
# Exact agreement with occurs_on over every date in the range.
cursor = _d(start)
end_date = _d(end)
expected: list[str] = []
while cursor <= end_date:
if occurs_on(rule, cursor):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected, (
f"disagreement for {rule.to_dict()} over [{start}, {end}]:\n"
f" occurrences_between: {result}\n"
f" per-date occurs_on: {expected}"
)
return result
# ---------------------------------------------------------------------------
# Exhaustive grid over rule shapes, intervals and windows
# ---------------------------------------------------------------------------
_GRID_RULES = [
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=2,
start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=3,
start_date="2026-03-15", end_date="2027-01-31"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
interval=2, start_date="2026-03-06",
end_date="2027-06-30"),
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 4},
start_date="2026-03-06"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
interval=2, start_date="2026-01-29"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=4, start_date="2026-03-06",
end_date="2028-12-31"),
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28"),
ScheduleRule(rule_type=RuleType.YEARLY, month=6, day_of_month=15,
interval=2, start_date="2026-06-15"),
]
_GRID_WINDOWS = [
("2026-01-01", "2026-03-31"), # mostly before start
("2026-03-01", "2026-03-01"), # single day == anchor
("2026-03-01", "2026-03-31"), # within window
("2026-08-01", "2026-08-31"), # mid-window month
("2026-12-01", "2027-01-31"), # year boundary
("2028-01-01", "2028-03-01"), # leap year span
("2029-01-01", "2029-12-31"), # far out
]
@pytest.mark.parametrize(
"rule_index,window_index",
[
(rule_index, window_index)
for rule_index in range(len(_GRID_RULES))
for window_index in range(len(_GRID_WINDOWS))
],
)
def test_grid_occurrences_between_matches_occurs_on(
rule_index, window_index
) -> None:
rule = _GRID_RULES[rule_index]
window = _GRID_WINDOWS[window_index]
_check_consistency(rule, window[0], window[1])
# ---------------------------------------------------------------------------
# Randomized property test (seeded, deterministic)
# ---------------------------------------------------------------------------
def _generate_random_rules(rng: random.Random, count: int) -> list[ScheduleRule]:
"""Random but valid rules: shapes, intervals, anchors, windows.
Any random combo the model rejects (genuinely invalid combinations)
is skipped — the property only needs VALID rules; the assertion at
the end bounds how many may be rejected.
"""
rules: list[ScheduleRule] = []
shapes = list(RuleType)
for index in range(count):
shape = shapes[rng.randrange(len(shapes))]
interval = rng.choice([1, 2, 3, 7])
anchor = datetime.date(
rng.choice([2024, 2025, 2026, 2027, 2028]),
rng.randrange(1, 13),
rng.randrange(1, 28),
)
end_offset = rng.choice([45, 120, 400, 800])
end = anchor + datetime.timedelta(days=end_offset)
common = {
"rule_type": shape,
"interval": interval,
"start_date": anchor.isoformat(),
"end_date": end.isoformat(),
}
try:
if shape is RuleType.WEEKLY:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.CUSTOM_DAYS:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.MONTHLY_DAY:
rules.append(ScheduleRule(
**common, day_of_month=rng.randrange(1, 32)
))
elif shape is RuleType.MONTHLY_WEEKDAY:
rules.append(ScheduleRule(
**common,
nth_weekday=rng.choice([-1, 1, 2, 3, 4, 5]),
nth_weekday_weekday=rng.randrange(7),
))
elif shape is RuleType.YEARLY:
rules.append(ScheduleRule(
**common,
month=rng.randrange(1, 13),
day_of_month=rng.choice([None, rng.randrange(1, 29)]),
))
else: # DAILY
rules.append(ScheduleRule(**common))
except RuleValidationError:
continue
assert len(rules) >= count * 3 // 4, "too many random rules rejected"
return rules
def test_random_rules_never_disagree_between_the_two_functions() -> None:
"""The property: for randomly generated rules and ranges,
occurrences_between(rule, s, e) == [d for d in s..e if occurs_on]."""
rng = random.Random(0xFEA7) # fixed seed for reproducibility
rules = _generate_random_rules(rng, count=40)
checked = 0
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
for offset_days, span_days in ((-30, 60), (0, 14), (10, 90),
(100, 200)):
range_start = anchor + datetime.timedelta(days=offset_days)
range_end = range_start + datetime.timedelta(days=span_days)
_check_consistency(
rule, range_start.isoformat(), range_end.isoformat()
)
checked += 1
assert checked >= 40 * 4 * 3 // 4, checked
def test_random_ranges_include_year_and_leap_spans() -> None:
"""Every rule is probed across a range that spans Jan 1 (twice) and
a February 29 — the Dec 1..Mar 1 two-year span always covers both."""
rng = random.Random(42)
rules = _generate_random_rules(rng, count=12)
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
probe_start = datetime.date(anchor.year, 12, 1)
probe_end = datetime.date(anchor.year + 2, 3, 1)
# The span always contains two Jan 1s and a Feb 28/29 pair.
assert probe_end >= probe_start
_check_consistency(
rule, probe_start.isoformat(), probe_end.isoformat()
)
# An additional probe pinned ON a Feb 29 window (2028 leap):
# every rule is cross-checked over Feb 1 - Mar 15 2028.
_check_consistency(rule, "2028-02-01", "2028-03-15")
def test_occurrences_between_single_day_range_matches_occurs_on() -> None:
"""A one-day range is the exact occurs_on of that date."""
for rule in _GRID_RULES:
for day in ("2026-03-05", "2026-03-15", "2027-06-15"):
single = occurrences_between(rule, day, day)
if occurs_on(rule, _d(day)):
assert single == [day], (rule.to_dict(), day, single)
else:
assert single == [], (rule.to_dict(), day, single)
-299
View File
@@ -1,299 +0,0 @@
"""Tests for weekly and custom day-set recurrence evaluation."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# 2026-09-01 is a TUESDAY (weekday 1).
# Anchor-week arithmetic (start_date = Tue Sep 1): week 0 spans
# Sep 1-7, week 1 spans Sep 8-14, week 2 spans Sep 15-21, and so on —
# whole weeks elapsed from the anchor, NOT calendar-Sunday weeks.
# A weekly Mon/Wed/Fri (0,2,4) rule at interval 1 anchored here fires
# on Wed Sep 2, Fri Sep 4 (both week 0), then Mon Sep 7, Wed Sep 9,
# Fri Sep 11 (week 1), Mon Sep 14... (week 2), and so on.
def test_weekly_fires_exactly_on_set_weekdays() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01",
)
# Sep 2026: 1=Tue(1), 2=Wed(2), 4=Fri(4), 7=Mon(0), 8=Tue(1), 9=Wed,
# 10=Thu(3), 11=Fri(4)
assert occurs_on(rule, _d("2026-09-01")) is False # Tue NOT in set
assert occurs_on(rule, _d("2026-09-02")) is True # Wed in set
assert occurs_on(rule, _d("2026-09-03")) is False # Thu not in set
assert occurs_on(rule, _d("2026-09-04")) is True # Fri in set
assert occurs_on(rule, _d("2026-09-05")) is False # Sat
assert occurs_on(rule, _d("2026-09-06")) is False # Sun
assert occurs_on(rule, _d("2026-09-07")) is True # Mon
assert occurs_on(rule, _d("2026-09-08")) is False # Tue not in set
def test_weekly_start_date_itself_may_not_be_in_set() -> None:
"""start_date anchors the WEEK count, not a firing day: a Tuesday
start for a Mon/Wed/Fri rule fires on Wed Sep 2, not Sep 1."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01",
)
assert occurs_on(rule, _d("2026-09-01")) is False # Tue=1, not in set
assert occurs_on(rule, _d("2026-09-02")) is True # Wed=2
assert occurs_on(rule, _d("2026-09-04")) is True # Fri=4
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, next week
def test_weekly_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={2}, # Wednesdays
start_date="2026-09-02", # a Wednesday
)
# Wed Sep 2 is the anchor day and in-set: fires. Wed Aug 26 (one
# week BEFORE the anchor, in-set) must not fire: pre-window.
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-08-26")) is False # Wed, before start
assert occurs_on(rule, _d("2026-09-09")) is True # Wed, next week
def test_weekly_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01", end_date="2026-09-10",
)
assert occurs_on(rule, _d("2026-09-09")) is True # Wed within end
assert occurs_on(rule, _d("2026-09-11")) is False # Fri after end
def test_weekly_interval_2_fires_on_alternate_weeks() -> None:
"""Interval 2: fires on set weekdays only in even week-offsets."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", # a Monday
)
# Week 0 (Sep 7-13): fires Mon Sep 7.
assert occurs_on(rule, _d("2026-09-07")) is True
# Week 1 (Sep 14-20): skipped.
assert occurs_on(rule, _d("2026-09-14")) is False
# Week 2 (Sep 21-27): fires Mon Sep 21.
assert occurs_on(rule, _d("2026-09-21")) is True
# Week 3 (Sep 28-Oct 4): skipped; Mon Sep 28 does not fire.
assert occurs_on(rule, _d("2026-09-28")) is False
# Week 4 (Oct 5-11): fires Mon Oct 5.
assert occurs_on(rule, _d("2026-10-05")) is True
def test_weekly_interval_3_every_third_week() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={3}, interval=3,
start_date="2026-09-03", # a Thursday
)
assert occurs_on(rule, _d("2026-09-03")) is True # week 0
assert occurs_on(rule, _d("2026-09-10")) is False # week 1
assert occurs_on(rule, _d("2026-09-17")) is False # week 2
assert occurs_on(rule, _d("2026-09-24")) is True # week 3
assert occurs_on(rule, _d("2026-10-15")) is True # week 6
def test_weekly_interval_2_never_fires_in_odd_weeks_across_months() -> None:
"""The week offset is anchored to start_date across month edges."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={2}, interval=2,
start_date="2026-09-02", # a Wednesday
)
# Week 0: Wed Sep 2 fires. Week 1: Wed Sep 9 skipped.
# Week 2: Wed Sep 16 fires. Week 3: Wed Sep 23 skipped.
# Week 4 (Oct): Wed Sep 30 fires (still week 4!). Week 5: Wed Oct 7 skipped.
assert occurs_on(rule, _d("2026-09-02")) is True
assert occurs_on(rule, _d("2026-09-09")) is False
assert occurs_on(rule, _d("2026-09-16")) is True
assert occurs_on(rule, _d("2026-09-23")) is False
assert occurs_on(rule, _d("2026-09-30")) is True
assert occurs_on(rule, _d("2026-10-07")) is False
assert occurs_on(rule, _d("2026-10-14")) is True
def test_custom_days_interval_1_behaves_like_weekly() -> None:
"""CUSTOM_DAYS with interval 1 behaves identically to WEEKLY."""
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
start_date="2026-09-01",
)
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
start_date="2026-09-01",
)
# Whole month of September must match exactly.
for day in range(1, 31):
date = datetime.date(2026, 9, day)
assert occurs_on(weekly, date) == occurs_on(custom, date), date
def test_custom_days_never_fires_before_start(tmp_path) -> None:
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={0},
start_date="2026-09-07", # a Monday
)
assert occurs_on(rule, _d("2026-09-06")) is False # Sun, before
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, on
def test_hand_checked_8_week_sequence_weekly_mon_wed_fri() -> None:
"""The done-condition's hand-checked sequence: 8 weeks of a
Mon/Wed/Fri rule anchored at Tue 2026-09-01, interval 1.
Anchor weeks from Tue Sep 1 (week 0 = Sep 1-7, week 1 = Sep 8-14,
... week 7 = Oct 20-26). Mon/Wed/Fri firings (interval 1, so every
in-set weekday fires regardless of week):
Week 0 (Sep 1-7): Wed 2, Fri 4 (Mon Sep 7 is ALSO week 0:
6 elapsed days // 7 = 0 — the anchor week
boundary sits on Sep 8, not on the Monday)
Week 1 (Sep 8-14): Wed 9, Fri 11, Mon 14 (13 elapsed days // 7 = 1)
Week 2 (Sep 15-21): Wed 16, Fri 18, Mon 21
Week 3 (Sep 22-28): Wed 23, Fri 25, Mon 28
Week 4 (Sep 29-Oct 5): Wed 30, Fri 2 (Oct), Mon 5 (Oct)
Week 5 (Oct 6-12): Wed 7, Fri 9, Mon 12
Week 6 (Oct 13-19): Wed 14, Fri 16, Mon 19
Week 7 (Oct 20-26): Wed 21, Fri 23, Mon 26
"""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-09-01", end_date="2026-10-26",
)
expected = [
"2026-09-02", "2026-09-04",
"2026-09-07", "2026-09-09", "2026-09-11",
"2026-09-14", "2026-09-16", "2026-09-18",
"2026-09-21", "2026-09-23", "2026-09-25",
"2026-09-28", "2026-09-30", "2026-10-02",
"2026-10-05", "2026-10-07", "2026-10-09",
"2026-10-12", "2026-10-14", "2026-10-16",
"2026-10-19", "2026-10-21", "2026-10-23", "2026-10-26",
]
actual = occurrences_between(rule, "2026-09-01", "2026-10-26")
assert actual == expected, (
f"diff: {set(expected) ^ set(actual)}"
)
def test_hand_checked_8_week_sequence_weekly_interval_2_mondays() -> None:
"""8 weeks of a Monday rule at interval 2, anchored Mon 2026-09-07
(week 0 = Sep 7-13): fires in weeks 0, 2, 4, 6 -> Mon Sep 7, Mon Sep 21,
Mon Oct 5, Mon Oct 19."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", end_date="2026-10-31",
)
actual = occurrences_between(rule, "2026-09-07", "2026-10-31")
assert actual == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
def test_custom_days_hand_checked_8_week_sequence() -> None:
"""8 weeks of a Tue/Thu/Sun rule (1,3,6) anchored Mon 2026-09-07:
fires Tue 8, Thu 10, Sun 13, then weekly."""
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 6},
start_date="2026-09-07",
)
# Anchor weeks from Mon Sep 7 (w0 = Sep 7-13 ... w7 = Oct 26-Nov 1):
# Tue/Thu/Sun firings per week, 8 weeks fully covered.
actual = occurrences_between(rule, "2026-09-07", "2026-11-01")
assert actual == [
"2026-09-08", "2026-09-10", "2026-09-13",
"2026-09-15", "2026-09-17", "2026-09-20",
"2026-09-22", "2026-09-24", "2026-09-27",
"2026-09-29", "2026-10-01", "2026-10-04",
"2026-10-06", "2026-10-08", "2026-10-11",
"2026-10-13", "2026-10-15", "2026-10-18",
"2026-10-20", "2026-10-22", "2026-10-25",
"2026-10-27", "2026-10-29", "2026-11-01",
]
def test_weekly_survives_53_week_year_boundary() -> None:
"""Anchor arithmetic never flips at Jan 1 (the ISO parity trap):
a weekly rule spanning New Year keeps its weekday pattern."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={3}, # Thursdays
start_date="2026-12-24", # a Thursday
)
# Thursdays around New Year 2026->2027: Dec 24, 31, Jan 7, 14.
assert occurs_on(rule, _d("2026-12-24")) is True
assert occurs_on(rule, _d("2026-12-31")) is True
assert occurs_on(rule, _d("2027-01-07")) is True
assert occurs_on(rule, _d("2027-01-14")) is True
# Interleaved days are not Thursdays.
assert occurs_on(rule, _d("2027-01-04")) is False # Monday
assert occurs_on(rule, _d("2027-01-05")) is False # Tuesday
def test_weekly_interval_2_across_year_boundary() -> None:
"""Interval weeks continue across Jan 1 with no parity flip."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={4}, interval=2,
start_date="2026-12-25", # a Friday
)
# Weeks: Dec 25 (w0) fires, Jan 1 (w1) skipped, Jan 8 (w2) fires,
# Jan 15 (w3) skipped, Jan 22 (w4) fires.
assert occurs_on(rule, _d("2026-12-25")) is True
assert occurs_on(rule, _d("2027-01-01")) is False
assert occurs_on(rule, _d("2027-01-08")) is True
assert occurs_on(rule, _d("2027-01-15")) is False
assert occurs_on(rule, _d("2027-01-22")) is True
def test_occurrences_between_weekly_disjoint(tmp_path) -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-09-01", end_date="2026-09-30",
)
assert occurrences_between(rule, "2026-10-01", "2026-10-31") == []
def test_occurrences_between_weekly_interval_2_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-07", end_date="2026-10-31",
)
result = occurrences_between(rule, "2026-09-01", "2026-10-31")
assert result == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
def test_occurrences_between_rejects_inverted_bounds() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01"
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-10-10", "2026-09-01")
def test_weekly_interval_2_non_monday_anchor_boundary() -> None:
"""The anchor week is anchored at start_date, NOT a calendar week:
a Tuesday-anchored Monday rule fires on Mon Sep 7 (week 0 extends
through Sep 7), skips Mon Sep 14 (week 1), fires Mon Sep 21 (week 2)."""
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
start_date="2026-09-01", # a TUESDAY anchor
)
# Sep 7 - Sep 1 = 6 days -> week 0 (even): fires.
assert occurs_on(rule, _d("2026-09-07")) is True
# Sep 14: 13 days -> week 1 (odd): skipped.
assert occurs_on(rule, _d("2026-09-14")) is False
# Sep 21: 20 days -> week 2 (even): fires.
assert occurs_on(rule, _d("2026-09-21")) is True
# Sep 28: 27 days -> week 3 (odd): skipped.
assert occurs_on(rule, _d("2026-09-28")) is False
# Oct 5: 34 days -> week 4 (even): fires.
assert occurs_on(rule, _d("2026-10-05")) is True
-233
View File
@@ -1,233 +0,0 @@
"""Tests for YEARLY recurrence with the leap-day clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings across four consecutive years (incl. leap 2028)
# ---------------------------------------------------------------------------
def test_yearly_june_15_four_consecutive_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 6, 15)) is True, year
assert occurs_on(rule, datetime.date(2026, 6, 14)) is False
assert occurs_on(rule, datetime.date(2026, 6, 16)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is False
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
def test_yearly_feb_15_across_four_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=15,
start_date="2026-02-15",
)
# Feb 15 exists every year; the leap day is irrelevant.
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 2, 15)) is True, year
def test_yearly_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2027-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is False
assert occurs_on(rule, datetime.date(2027, 6, 15)) is True
def test_yearly_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15", end_date="2028-06-15",
)
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False
# ---------------------------------------------------------------------------
# Leap-day policy: Feb 29 rule fires Feb 28 in non-leap years
# ---------------------------------------------------------------------------
def test_yearly_feb_29_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2026 non-leap: no Feb 29 — the clamp fires Feb 28.
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# 2027 non-leap: same clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
def test_yearly_feb_29_fires_feb_29_in_leap_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2028 leap: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_yearly_feb_29_four_consecutive_years() -> None:
"""The done-condition's four-year sweep: 2026 (clamp 28), 2027
(clamp 28), 2028 (leap: nominal 29), 2029 (clamp 28)."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2029, 2, 28)) is True
# No other day in those Februaries fires.
for year, non_firing in (
(2026, (27,)), (2027, (26, 27)), (2028, (27,)),
(2029, (26, 27)),
):
for day in non_firing:
assert occurs_on(rule, datetime.date(year, 2, day)) is False
def test_yearly_feb_30_is_impossible_by_model() -> None:
"""The model rejects day_of_month 30+ for February? No — 30/31 are
accepted (1..31) and clamp to the month end like MONTHLY_DAY."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=30,
start_date="2027-02-28",
)
# Non-leap 2027: Feb 28 is the month end — the clamp fires it.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# Leap 2028: Feb 29 is the month end — clamps there.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
# ---------------------------------------------------------------------------
# Day-less YEARLY rules fire on the first of the month
# ---------------------------------------------------------------------------
def test_yearly_without_day_fires_on_first_of_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, start_date="2026-01-01",
)
assert occurs_on(rule, datetime.date(2026, 6, 1)) is True
assert occurs_on(rule, datetime.date(2026, 6, 2)) is False
assert occurs_on(rule, datetime.date(2026, 5, 1)) is False
assert occurs_on(rule, datetime.date(2027, 6, 1)) is True
# ---------------------------------------------------------------------------
# Interval: every N years
# ---------------------------------------------------------------------------
def test_yearly_interval_2_every_second_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True # y0
assert occurs_on(rule, datetime.date(2027, 6, 15)) is False # y1
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True # y2
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False # y3
assert occurs_on(rule, datetime.date(2030, 6, 15)) is True # y4
def test_yearly_interval_2_leap_day_every_second_year() -> None:
"""A Feb 29 rule at interval 2 anchored 2026 fires in even years:
2026 clamps (Feb 28), 2027 skips, 2028 hits the nominal Feb 29."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29, interval=2,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # y0 clamp
assert occurs_on(rule, datetime.date(2027, 2, 28)) is False # y1
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True # y2 nominal
assert occurs_on(rule, datetime.date(2029, 2, 28)) is False # y3
assert occurs_on(rule, datetime.date(2030, 2, 28)) is True # y4 clamp
def test_yearly_interval_3_across_start_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=10, interval=3,
start_date="2026-03-10",
)
assert occurs_on(rule, datetime.date(2026, 3, 10)) is True
assert occurs_on(rule, datetime.date(2027, 3, 10)) is False
assert occurs_on(rule, datetime.date(2028, 3, 10)) is False
assert occurs_on(rule, datetime.date(2029, 3, 10)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_yearly_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
result = occurrences_between(rule, "2026-01-01", "2029-12-31")
assert result == [
"2026-02-28", "2027-02-28", "2028-02-29", "2029-02-28",
]
def test_yearly_occurrences_between_disjoint_range() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
# The rule only ever fires in June: other months are empty.
assert occurrences_between(rule, "2027-01-01", "2027-05-31") == []
assert occurrences_between(rule, "2027-07-01", "2027-12-31") == []
def test_yearly_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2029-01-01", "2026-01-01")
# ---------------------------------------------------------------------------
# Day of month 31 in a 30-day month: clamp consistency with MONTHLY_DAY
# ---------------------------------------------------------------------------
def test_yearly_june_31_clamps_to_june_30() -> None:
"""June has 30 days: a June 31st yearly rule clamps to June 30."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=31,
start_date="2026-06-30",
)
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True
assert occurs_on(rule, datetime.date(2027, 6, 30)) is True
assert occurs_on(rule, datetime.date(2026, 6, 29)) is False
+77 -389
View File
@@ -15,10 +15,8 @@ from custom_components.nestquest.schema import (
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
SCHEMA_V1_QUEST_INSTANCES_DDL,
SCHEMA_V1_TASK_DEFINITIONS_DDL,
SCHEMA_V1_TASK_INSTANCES_DDL,
async_apply_ddl,
)
@@ -75,17 +73,15 @@ def _insert_rule(database, **overrides):
return result.lastrowid
def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
"""Insert one valid quest_definition, returning the definition id.
def _insert_definition(database, child_id=1, rule_id=1, **overrides):
"""Insert one valid task_definition, returning the definition id.
``assignees`` rows are written to ``quest_definition_assignees``
after the definition insert (the definition itself carries no
child). Pass ``assignees=None`` for an unassigned definition.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies.
"""
values: dict[str, object] = {
"title": "Brush teeth",
"child_id": child_id,
"schedule_rule_id": rule_id,
"created_at": "2026-09-13T00:00:00+00:00",
}
@@ -95,19 +91,11 @@ def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
placeholders = ", ".join("?" for _ in values)
result = _run(
database.execute(
f"INSERT INTO quest_definitions ({columns}) VALUES ({placeholders})",
f"INSERT INTO task_definitions ({columns}) VALUES ({placeholders})",
tuple(values.values()),
)
)
assert result.lastrowid is not None
for child_id in assignees or ():
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(result.lastrowid, child_id),
)
)
return result.lastrowid
@@ -168,17 +156,15 @@ def _insert_override(database, child_id, **overrides):
# ---------------------------------------------------------------------------
def test_schema_v1_statements_compose_the_eight_tables() -> None:
def test_schema_v1_statements_compose_the_six_tables() -> None:
assert SCHEMA_V1_STATEMENTS == [
*SCHEMA_V1_CHILDREN_DDL,
*SCHEMA_V1_ADMIN_USERS_DDL,
*SCHEMA_V1_SCHEDULE_RULES_DDL,
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_TASK_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
]
@@ -270,17 +256,17 @@ def test_schedule_rules_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close())
def test_quest_definitions_table_exists_after_applying_ddl(tmp_path) -> None:
def test_task_definitions_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "definitions.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'quest_definitions'"
"AND name = 'task_definitions'"
)
)
assert row is not None and row[0] == "quest_definitions"
assert row is not None and row[0] == "task_definitions"
finally:
_run(database.close())
@@ -306,16 +292,17 @@ def test_schedule_rules_columns_types_and_constraints(tmp_path) -> None:
_run(database.close())
def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
def test_task_definitions_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-columns.db")
try:
_apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
columns = _run(database.fetch_all("PRAGMA table_info(task_definitions)"))
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1),
("title", "TEXT", 1, None, 0),
("description", "TEXT", 0, None, 0),
("icon", "TEXT", 0, None, 0),
("child_id", "INTEGER", 1, None, 0),
("schedule_rule_id", "INTEGER", 1, None, 0),
("due_time", "TEXT", 0, None, 0),
("is_active", "INTEGER", 1, "1", 0),
@@ -325,68 +312,15 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
_run(database.close())
def test_quest_definitions_foreign_keys_declared(tmp_path) -> None:
def test_task_definitions_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-fks.db")
try:
_apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_definitions)"))
fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_definitions)"))
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("schedule_rules", "schedule_rule_id", "id"),
}
finally:
_run(database.close())
def test_quest_definition_assignees_table_exists_after_applying_ddl(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'quest_definition_assignees'"
)
)
assert row is not None and row[0] == "quest_definition_assignees"
finally:
_run(database.close())
def test_quest_definition_assignees_columns_types_and_constraints(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees-columns.db")
try:
_apply(database)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definition_assignees)")
)
# cid, name, type, notnull, dflt_value, pk — both columns are
# the two-part primary key, so pk is 1 and 2.
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("definition_id", "INTEGER", 1, None, 1),
("child_id", "INTEGER", 1, None, 2),
]
finally:
_run(database.close())
def test_quest_definition_assignees_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-fks.db")
try:
_apply(database)
fks = _run(
database.fetch_all(
"PRAGMA foreign_key_list(quest_definition_assignees)"
)
)
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("quest_definitions", "definition_id", "id"),
("children", "child_id", "id"),
("schedule_rules", "schedule_rule_id", "id"),
}
finally:
_run(database.close())
@@ -585,7 +519,7 @@ def test_failed_statement_rolls_back_whole_application(tmp_path) -> None:
# ---------------------------------------------------------------------------
# schedule_rules and quest_definitions constraint enforcement
# schedule_rules and task_definitions constraint enforcement
# ---------------------------------------------------------------------------
@@ -879,7 +813,7 @@ def test_schedule_rules_start_date_not_null_enforced(tmp_path) -> None:
_run(database.close())
def test_quest_definitions_insert_requires_existing_child_and_rule(
def test_task_definitions_insert_requires_existing_child_and_rule(
tmp_path,
) -> None:
database = _open_db(tmp_path / "definitions-fk-unknown.db")
@@ -892,279 +826,33 @@ def test_quest_definitions_insert_requires_existing_child_and_rule(
)
)
rule_id = _insert_rule(database)
definition_id = _insert_definition(database, rule_id=rule_id)
definition_id = _insert_definition(
database, child_id=1, rule_id=rule_id
)
row = _run(
database.fetch_one(
"SELECT title, schedule_rule_id FROM quest_definitions "
"SELECT title, child_id, schedule_rule_id FROM task_definitions "
"WHERE id = ?",
(definition_id,),
)
)
assert row == ("Brush teeth", rule_id)
assignee = _run(
database.fetch_one(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ?",
(definition_id,),
)
)
assert assignee == (1,)
assert row == ("Brush teeth", 1, rule_id)
finally:
_run(database.close())
def test_quest_definition_assignees_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-bad-child.db")
def test_task_definitions_unknown_child_id_fails(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-bad-child.db")
try:
_apply(database)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=None)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, 999)",
(definition_id,),
)
)
_insert_definition(database, child_id=999)
finally:
_run(database.close())
def test_quest_definition_assignees_unknown_definition_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-bad-definition.db")
try:
_apply(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (999, 1)"
)
)
finally:
_run(database.close())
def test_quest_definition_assignees_duplicate_pair_fails(tmp_path) -> None:
database = _open_db(tmp_path / "assignees-duplicate.db")
try:
_apply(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
_insert_rule(database)
definition_id = _insert_definition(database)
with pytest.raises(sqlite3.IntegrityError):
_run(
database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, 1)",
(definition_id,),
)
)
finally:
_run(database.close())
def test_quest_definition_windows_table_exists_after_applying_ddl(
tmp_path,
) -> None:
database = _open_db(tmp_path / "windows.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'quest_definition_windows'"
)
)
assert row is not None and row[0] == "quest_definition_windows"
finally:
_run(database.close())
def test_quest_definition_windows_columns_types_and_constraints(
tmp_path,
) -> None:
database = _open_db(tmp_path / "windows-columns.db")
try:
_apply(database)
columns = _run(
database.fetch_all("PRAGMA table_info(quest_definition_windows)")
)
# cid, name, type, notnull, dflt_value, pk — the composite
# primary key spans definition_id and window.
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("definition_id", "INTEGER", 1, None, 1),
("window", "TEXT", 1, None, 2),
("due_time", "TEXT", 0, None, 0),
]
finally:
_run(database.close())
def test_quest_definition_windows_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "windows-fks.db")
try:
_apply(database)
fks = _run(
database.fetch_all(
"PRAGMA foreign_key_list(quest_definition_windows)"
)
)
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("quest_definitions", "definition_id", "id"),
}
finally:
_run(database.close())
def test_quest_definition_windows_valid_rows_round_trip(tmp_path) -> None:
database = _open_db(tmp_path / "windows-roundtrip.db")
try:
_apply(database)
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Ada", "2026-09-13T00:00:00+00:00"),
)
)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=None)
for window, due in (
("morning", "09:00"),
("afternoon", None),
("evening", "19:30"),
):
_run(
database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) VALUES (?, ?, ?)",
(definition_id, window, due),
)
)
rows = _run(
database.fetch_all(
"SELECT window, due_time FROM quest_definition_windows "
"WHERE definition_id = ? ORDER BY window",
(definition_id,),
)
)
assert rows == [
("afternoon", None),
("evening", "19:30"),
("morning", "09:00"),
]
finally:
_run(database.close())
@pytest.mark.parametrize(
"window", ["Midnight", "MORNING", "noon", "", "evenings"]
)
def test_quest_definition_windows_window_check_enforced(
tmp_path, window
) -> None:
database = _open_db(tmp_path / "windows-check.db")
try:
_apply(database)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=None)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_run(
database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) VALUES (?, ?, NULL)",
(definition_id, window),
)
)
finally:
_run(database.close())
def test_quest_definition_windows_duplicate_window_fails(tmp_path) -> None:
database = _open_db(tmp_path / "windows-duplicate.db")
try:
_apply(database)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=None)
_run(
database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (?, 'morning', NULL)",
(definition_id,),
)
)
with pytest.raises(sqlite3.IntegrityError):
_run(
database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (?, 'morning', '09:00')",
(definition_id,),
)
)
finally:
_run(database.close())
def test_quest_definition_windows_unknown_definition_fails(tmp_path) -> None:
database = _open_db(tmp_path / "windows-bad-definition.db")
try:
_apply(database)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_run(
database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (999, 'morning', NULL)"
)
)
finally:
_run(database.close())
def test_quest_definition_assignees_multiple_children_allowed(
tmp_path,
) -> None:
database = _open_db(tmp_path / "assignees-multi.db")
try:
_apply(database)
for name in ("Ada", "Ben", "Cleo"):
_run(
database.execute(
"INSERT INTO children (display_name, created_at) "
"VALUES (?, ?)",
(name, "2026-09-13T00:00:00+00:00"),
)
)
_insert_rule(database)
definition_id = _insert_definition(database, assignees=(1, 2, 3))
rows = _run(
database.fetch_all(
"SELECT child_id FROM quest_definition_assignees "
"WHERE definition_id = ? ORDER BY child_id",
(definition_id,),
)
)
assert [row[0] for row in rows] == [1, 2, 3]
finally:
_run(database.close())
def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
def test_task_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-bad-rule.db")
try:
_apply(database)
@@ -1175,24 +863,24 @@ def test_quest_definitions_unknown_schedule_rule_id_fails(tmp_path) -> None:
)
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_definition(database, rule_id=999)
_insert_definition(database, child_id=1, rule_id=999)
finally:
_run(database.close())
def test_quest_definitions_is_active_check_enforced(tmp_path) -> None:
def test_task_definitions_is_active_check_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-is-active.db")
try:
_apply(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(database, rule_id=1, is_active=2)
_insert_definition(database, child_id=1, rule_id=1, is_active=2)
finally:
_run(database.close())
@pytest.mark.parametrize("bad_is_active", ["abc", 1.5])
def test_quest_definitions_is_active_non_integer_fails(
def test_task_definitions_is_active_non_integer_fails(
tmp_path, bad_is_active
) -> None:
database = _open_db(tmp_path / "definitions-is-active-typeof.db")
@@ -1207,37 +895,37 @@ def test_quest_definitions_is_active_non_integer_fails(
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_definition(
database, rule_id=1, is_active=bad_is_active
database, child_id=1, rule_id=1, is_active=bad_is_active
)
finally:
_run(database.close())
def test_quest_definitions_title_not_null_enforced(tmp_path) -> None:
def test_task_definitions_title_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-title.db")
try:
_apply(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_definition(database, rule_id=1, title=None)
_insert_definition(database, child_id=1, rule_id=1, title=None)
finally:
_run(database.close())
def test_quest_definitions_created_at_not_null_enforced(tmp_path) -> None:
def test_task_definitions_created_at_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-created-at.db")
try:
_apply(database)
_insert_rule(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_definition(
database, rule_id=1, created_at=None
database, child_id=1, rule_id=1, created_at=None
)
finally:
_run(database.close())
def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None:
def test_task_definitions_defaults_apply_on_insert(tmp_path) -> None:
database = _open_db(tmp_path / "definitions-defaults.db")
try:
_apply(database)
@@ -1249,11 +937,11 @@ def test_quest_definitions_defaults_apply_on_insert(tmp_path) -> None:
)
_insert_rule(database)
definition_id = _insert_definition(
database, rule_id=1, is_active=_OMIT
database, child_id=1, rule_id=1, is_active=_OMIT
)
row = _run(
database.fetch_one(
"SELECT is_active, due_time FROM quest_definitions WHERE id = ?",
"SELECT is_active, due_time FROM task_definitions WHERE id = ?",
(definition_id,),
)
)
@@ -1278,10 +966,10 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None:
)
)
_insert_rule(database)
_insert_definition(database, rule_id=1)
_insert_definition(database, child_id=1, rule_id=1)
_apply(database)
counts = []
for table in ("children", "schedule_rules", "quest_definitions"):
for table in ("children", "schedule_rules", "task_definitions"):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
counts.append(row[0])
assert counts == [1, 1, 1]
@@ -1718,7 +1406,7 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
# ---------------------------------------------------------------------------
# quest_instances and completion_events: helpers
# task_instances and completion_events: helpers
# ---------------------------------------------------------------------------
@@ -1740,7 +1428,7 @@ def _insert_instance(database, definition_id=1, **overrides):
placeholders = ", ".join("?" for _ in values)
result = _run(
database.execute(
f"INSERT INTO quest_instances ({columns}) VALUES ({placeholders})",
f"INSERT INTO task_instances ({columns}) VALUES ({placeholders})",
tuple(values.values()),
)
)
@@ -1778,28 +1466,28 @@ def _insert_event(database, instance_id=1, **overrides):
def _setup_definition(database) -> None:
"""Create one child, one schedule rule and one quest definition."""
"""Create one child, one schedule rule and one task definition."""
_child(database)
_insert_rule(database)
_insert_definition(database, rule_id=1)
_insert_definition(database, child_id=1, rule_id=1)
# ---------------------------------------------------------------------------
# quest_instances and completion_events: DDL shape
# task_instances and completion_events: DDL shape
# ---------------------------------------------------------------------------
def test_quest_instances_table_exists_after_applying_ddl(tmp_path) -> None:
def test_task_instances_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'quest_instances'"
"AND name = 'task_instances'"
)
)
assert row is not None and row[0] == "quest_instances"
assert row is not None and row[0] == "task_instances"
finally:
_run(database.close())
@@ -1819,11 +1507,11 @@ def test_completion_events_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close())
def test_quest_instances_columns_types_and_constraints(tmp_path) -> None:
def test_task_instances_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-columns.db")
try:
_apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(quest_instances)"))
columns = _run(database.fetch_all("PRAGMA table_info(task_instances)"))
# cid, name, type, notnull, dflt_value, pk
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1),
@@ -1856,14 +1544,14 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
_run(database.close())
def test_quest_instances_foreign_keys_declared(tmp_path) -> None:
def test_task_instances_foreign_keys_declared(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-fks.db")
try:
_apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(quest_instances)"))
fks = _run(database.fetch_all("PRAGMA foreign_key_list(task_instances)"))
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("quest_definitions", "definition_id", "id"),
("task_definitions", "definition_id", "id"),
("children", "child_id", "id"),
}
finally:
@@ -1877,14 +1565,14 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
fks = _run(database.fetch_all("PRAGMA foreign_key_list(completion_events)"))
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {
("quest_instances", "instance_id", "id"),
("task_instances", "instance_id", "id"),
("children", "child_id", "id"),
}
finally:
_run(database.close())
def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
def test_task_instances_unique_index_on_definition_and_date(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-unique.db")
try:
_apply(database)
@@ -1892,7 +1580,7 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
rows = _run(
database.fetch_all(
"SELECT name, sql FROM sqlite_master WHERE type = 'index' "
"AND tbl_name = 'quest_instances'"
"AND tbl_name = 'task_instances'"
)
)
implicit = [(name, sql) for name, sql in rows if sql is None]
@@ -1911,7 +1599,7 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
row = _run(
database.fetch_one(
"SELECT COUNT(DISTINCT name) FROM pragma_index_list"
"('quest_instances') WHERE \"unique\" = 1"
"('task_instances') WHERE \"unique\" = 1"
)
)
assert row == (1,)
@@ -1920,11 +1608,11 @@ def test_quest_instances_unique_index_on_definition_and_date(tmp_path) -> None:
# ---------------------------------------------------------------------------
# quest_instances: constraint enforcement
# task_instances: constraint enforcement
# ---------------------------------------------------------------------------
def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
def test_task_instances_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-valid.db")
try:
_apply(database)
@@ -1933,7 +1621,7 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
row = _run(
database.fetch_one(
"SELECT definition_id, child_id, due_date, due_time, generated_at "
"FROM quest_instances WHERE id = ?",
"FROM task_instances WHERE id = ?",
(instance_id,),
)
)
@@ -1942,7 +1630,7 @@ def test_quest_instances_valid_insert_round_trips(tmp_path) -> None:
_run(database.close())
def test_quest_instances_due_time_round_trips(tmp_path) -> None:
def test_task_instances_due_time_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-due-time.db")
try:
_apply(database)
@@ -1952,7 +1640,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None:
)
row = _run(
database.fetch_one(
"SELECT due_time FROM quest_instances WHERE id = ?",
"SELECT due_time FROM task_instances WHERE id = ?",
(instance_id,),
)
)
@@ -1961,7 +1649,7 @@ def test_quest_instances_due_time_round_trips(tmp_path) -> None:
_run(database.close())
def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
def test_task_instances_duplicate_definition_date_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-duplicate.db")
try:
_apply(database)
@@ -1973,7 +1661,7 @@ def test_quest_instances_duplicate_definition_date_fails(tmp_path) -> None:
_run(database.close())
def test_quest_instances_same_date_different_definitions_allowed(
def test_task_instances_same_date_different_definitions_allowed(
tmp_path,
) -> None:
database = _open_db(tmp_path / "task-instances-multi-def.db")
@@ -1987,29 +1675,29 @@ def test_quest_instances_same_date_different_definitions_allowed(
)
)
rule_id = _insert_rule(database)
_insert_definition(database, rule_id=rule_id, assignees=(2,))
_insert_definition(database, child_id=2, rule_id=rule_id)
_insert_instance(database, definition_id=1, due_date="2026-09-14")
_insert_instance(database, definition_id=2, due_date="2026-09-14")
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances"))
assert row == (2,)
finally:
_run(database.close())
def test_quest_instances_same_definition_different_dates_allowed(tmp_path) -> None:
def test_task_instances_same_definition_different_dates_allowed(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-multi-date.db")
try:
_apply(database)
_setup_definition(database)
_insert_instance(database, definition_id=1, due_date="2026-09-14")
_insert_instance(database, definition_id=1, due_date="2026-09-15")
row = _run(database.fetch_one("SELECT COUNT(*) FROM quest_instances"))
row = _run(database.fetch_one("SELECT COUNT(*) FROM task_instances"))
assert row == (2,)
finally:
_run(database.close())
def test_quest_instances_unknown_definition_fails(tmp_path) -> None:
def test_task_instances_unknown_definition_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-bad-def.db")
try:
_apply(database)
@@ -2020,7 +1708,7 @@ def test_quest_instances_unknown_definition_fails(tmp_path) -> None:
_run(database.close())
def test_quest_instances_unknown_child_fails(tmp_path) -> None:
def test_task_instances_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-bad-child.db")
try:
_apply(database)
@@ -2031,7 +1719,7 @@ def test_quest_instances_unknown_child_fails(tmp_path) -> None:
_run(database.close())
def test_quest_instances_due_date_not_null_enforced(tmp_path) -> None:
def test_task_instances_due_date_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-due-null.db")
try:
_apply(database)
@@ -2042,7 +1730,7 @@ def test_quest_instances_due_date_not_null_enforced(tmp_path) -> None:
_run(database.close())
def test_quest_instances_generated_at_not_null_enforced(tmp_path) -> None:
def test_task_instances_generated_at_not_null_enforced(tmp_path) -> None:
database = _open_db(tmp_path / "task-instances-generated-null.db")
try:
_apply(database)
@@ -2338,8 +2026,8 @@ def test_applying_full_ddl_twice_keeps_rows(tmp_path) -> None:
for table in (
"children",
"schedule_rules",
"quest_definitions",
"quest_instances",
"task_definitions",
"task_instances",
"completion_events",
):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
-467
View File
@@ -1,467 +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())
_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())
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.
"""
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.glob("*.py")):
if pattern.search(py.read_text()):
offenders.append(py.name)
assert offenders == [], (
f"code paths that could delete/overwrite the database: {offenders}"
)
+2 -40
View File
@@ -101,20 +101,12 @@ def test_executor_helper_creates_directory(tmp_path) -> None:
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.
"""
"""sqlite3 is banned package-wide except db.py, the connection wrapper."""
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:
if py_file.name == "db.py":
continue
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
@@ -129,36 +121,6 @@ def test_no_sqlite3_import_outside_db_module() -> None:
)
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__))
Generated
+1 -1
View File
@@ -22,7 +22,7 @@ wheels = [
[[package]]
name = "nestquest"
version = "0.3.0"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]