Compare commits

...
Author SHA1 Message Date
overseer 57d354b54c feat: clamp materialize start to today (no past instances) 2026-09-17 16:10:36 -04:00
overseer f6aad9d20f HA-local timezone + DST boundary tests (Feature 07, task 8) (#82) 2026-09-17 16:06:41 -04:00
overseer 5706d8b7a6 Startup backfill + regenerate service (Feature 07, task 7) (#81) 2026-09-17 15:42:10 -04:00
overseer be6367b600 Schedule the daily generation run at rollover (Feature 07, task 6) (#80) 2026-09-17 15:10:57 -04:00
overseer ca26ced59f Regenerate future instances on presence change (Feature 07, task 5) (#79) 2026-09-17 14:50:50 -04:00
overseer 2146531665 Regenerate future instances on definition change (Feature 07, task 4) (#78) 2026-09-17 14:24:22 -04:00
overseer 168bb7b2f1 Skip completed instances during materialization (Feature 07, task 3) (#77) 2026-09-17 14:09:42 -04:00
overseer 4544f78390 Three-run idempotency with stable instance ids (Feature 07, task 2) (#76) 2026-09-17 14:04:02 -04:00
overseer 9836a6f524 Core generation walk: assignees x windows fan-out with presence (Feature 07, task 1) (#75) 2026-09-17 14:01:20 -04:00
overseer d6823a54bc Merge pull request 'Feature 06: Quest Definitions and Assignment -> dev' (#73) from feature/b61a578c-2139-486e-af46-77303a185386-quest-definitions-assignment into dev 2026-09-17 12:10:07 -04:00
overseer fa40e21dc2 Fix feature-integration review findings F06-001 and F06-002 (#74) 2026-09-17 12:06:47 -04:00
overseer e60a9892ff Tests for the quest definition business layer (Feature 06, task 7) (#72) 2026-09-17 09:58:15 -04:00
overseer 1432dba74c Quest definition query helpers for materialization (Feature 06, task 6) (#71) 2026-09-17 09:49:54 -04:00
overseer 1b6973efd3 Deactivate and reactivate a quest definition (Feature 06, task 5) (#70) 2026-09-17 09:40:15 -04:00
overseer 4606d7ddd0 Assign and unassign children on a quest definition (Feature 06, task 4) (#69) 2026-09-17 09:21:21 -04:00
overseer 802258d431 Edit a quest definition's metadata, schedule rule, and windows (Feature 06, task 3) (#68) 2026-09-17 09:06:11 -04:00
overseer d9cdac87d7 Create a quest definition with rule, assignees, and windows (Feature 06, task 2) (#67) 2026-09-17 08:41:56 -04:00
overseer cbd29f5ab7 Map ScheduleRule models to and from schedule_rules storage (Feature 06, task 1) (#66) 2026-09-17 08:11:19 -04:00
orchestrator-agent 58b11b59cf Merge pull request 'Feature 05: Presence and Custody Schedules -> dev' (#64) from feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules into dev 2026-09-15 18:33:54 -04:00
dev_agent 9d21f29eea Merge pull request 'Year-boundary and leap-year regression test for custody rotation (Feature 05, task 7)' (#63) from task/5841f120-parity-regression into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 18:32:45 -04:00
Joshua King fec607f039 Review fixes: correct 2015 anchor weekday and ISO rollover facts
Review round 2 (Codex gpt-5.6-terra, 2 LOW): 2015-01-06 is a Tuesday,
not a Monday — the anchor is now the computed first Monday of January
(asserted weekday 0); the docstring names the ISO-week rollover
precisely (the Monday on or after January 4th — January 4th in both
walked years, which end ISO week 53) instead of 'January 1st'. Suite:
1005 passed.
2026-09-15 18:31:38 -04:00
Joshua King a18c0060e4 Review fix: extend the parity walk through the following January
Review round 1 (Codex gpt-5.6-terra, MEDIUM): the walk stopped at
December 31st, so the boundary block's next-January days went
unchecked and a year-resetting implementation would pass. The walk
now runs through January 14th of the following year, asserting block
uniformity across the December 31st -> January 1st transition
(including the post-53-week jump), and the walked-year day count is
verified independently of the walk. Suite: 1005 passed.
2026-09-15 18:28:39 -04:00
Joshua King 20d7efb04b Year-boundary and leap-year regression guard (Feature 05, task 7)
The week-parity regression test (D-004): walks a two-week alternating
rotation day by day across a FULL 366-day leap year (2020) and a full
365-day year with 53 ISO weeks (2015), asserting the present-day
sequence never breaks or inverts, including January 1st.  The
expectation is derived from the anchor's 7-day blocks — each block
uniform, consecutive blocks alternating — not from the engine's own
index formula, so a parity-based implementation that resets on
January 1st breaks block uniformity or alternation (most visibly in
the block spanning the boundary).  Year facts asserted: 2020 = 366
days and ISO week 53; 2015 = 365 days and ISO week 53.  The test
docstring names itself as the regression guard.  Suite: 1004 passed.
2026-09-15 18:25:32 -04:00
dev_agent 6551a87bd6 Merge pull request 'Provide a next-N-present-days preview function (Feature 05, task 6)' (#62) from task/7f0190c4-present-preview into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 18:23:35 -04:00
Joshua King e9dc0a1445 Test fix: week 0 runs Mon Jan 5 through Sun Jan 11
The six-day preview expectation missed that Jan 10-11 are still in
the present week (the away week starts Jan 12). Suite green before
commit: 1003 passed.
2026-09-15 18:20:42 -04:00
Joshua King 1bb0617471 Next-N-present-days preview (Feature 05, task 6)
PresenceEngine.next_present_dates(child_id, start_date, count):
walks forward from the INCLUSIVE start date through is_present, so
overrides and the pattern are honoured identically — this is the
preview the admin Schedule tab renders before a parent saves a
custody pattern. Count must be a positive int; the scan is capped at
two years (MAX_PREVIEW_SCAN_DAYS) and an always-absent child raises
a clear ValueError instead of looping forever. Tests: known two-week
pattern (straight five-day week, mid-week start skipping the absent
week), inclusive start, override blocking the preview start,
schedule-free child, input validation, always-absent error. Suite:
1002 passed.
2026-09-15 18:19:53 -04:00
dev_agent 221ab2c20b Merge pull request 'Make overrides take precedence over the repeating pattern (Feature 05, task 5)' (#61) from task/6a1e14aa-override-precedence into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 18:17:40 -04:00
Joshua King bdf3513609 Test fix: build the frozen-snapshot engine on the caller's list
Review round 3 (Codex gpt-5.6-terra, LOW): the helper copied the list,
so the mutation could never have affected even a shallow snapshot.
The engine is now constructed directly on the caller's list, making
the freeze regression-proof. Suite green before commit: 997 passed.
2026-09-15 18:16:40 -04:00
Joshua King 830d663a45 Test fix: tuples raise AttributeError for append, not TypeError
The frozen-snapshot test expected TypeError from tuple.append; tuples
have no append at all. Suite green BEFORE commit: 997 passed.
2026-09-15 18:15:13 -04:00
Joshua King bff2f4ff5e Review fix: freeze the engine's overrides snapshot
Review round 1 (Codex gpt-5.6-terra, MEDIUM): the overrides mapping
was shallow-copied, leaving caller-owned lists mutable through
_overrides — a later append would change is_present results. Each
validated list is copied into an immutable tuple before wrapping;
tests prove post-construction appends change nothing and the snapshot
refuses mutation. Suite: 996 passed.
2026-09-15 18:13:48 -04:00
Joshua King 8f04d93363 Overrides take precedence over the pattern (Feature 05, task 5)
PresenceEngine gains an overrides snapshot (child_id -> list of
PresenceOverride): is_present checks covering overrides BEFORE the
pattern and returns the override's flag as the answer — an absent
override on a normally-present day returns False, a present one on a
normally-absent day returns True, and an override on a child with no
schedule still applies (holidays work for the always-present sibling).
Snapshot validation mirrors the schedules': dict of lists keyed by the
override's own child id, exposed read-only. The DAO layer guarantees
no overlapping overrides per child; a hand-built snapshot that carries
them resolves deterministically to the LATEST start date (documented).
Tests: all three precedence cases, span edges (outside days stay on
the pattern), snapshot rejection matrix, deterministic overlap
tie-break. Suite: 995 passed.
2026-09-15 18:12:16 -04:00
dev_agent d9326deeb5 Merge pull request 'Implement presence overrides for holidays and swapped weeks (Feature 05, task 4)' (#60) from task/46a867a4-override-overlap into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 18:08:07 -04:00
Joshua King 94f54523f9 Review fix: gate the override race test inside A's transaction
Review round 1 (Codex gpt-5.6-terra, LOW): the racing test completed
synchronously and proved nothing. The executor now gates task A's
INSERT — inside its open transaction, after its overlap probe — so
task B queues on the connection lock, re-runs the probe against A's
committed row, and is rejected; exactly one lands. Suite: 990 passed.
2026-09-15 18:06:44 -04:00
Joshua King 51b1388233 Fix: inverted ranges skip the overlap probe and hit the schema CHECK
The matrix test's end-before-start probe (expecting the schema's
end>=start IntegrityError) hit the new overlap query first, since an
inverted range lexically 'overlaps' the seeded row. The overlap
check now runs only for well-ordered ranges; invalid ones fall
through to the INSERT and keep the IntegrityError contract. Suite:
990 passed.
2026-09-15 18:03:49 -04:00
Joshua King 744a40dcb5 Presence override overlap rejection naming the conflict (Feature 05, task 4)
PresenceOverridesDao.create now refuses an override that overlaps any
existing override of the SAME child (inclusive boundaries): the check
runs inside the same transaction as the INSERT under the connection
lock, so a racing create cannot slip a conflict between check and
write — a deterministic race test proves exactly one of two concurrent
overlapping creates lands. The ValueError names the conflicting
override's id, its range, and its flag. Round-trip, per-child scope,
and remove-then-recreate tests added. Suite: 990 passed.
2026-09-15 18:03:00 -04:00
dev_agent e15648232f Merge pull request 'Implement is_present with always-present default (Feature 05, task 3)' (#59) from task/5059d16c-is-present into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 18:00:06 -04:00
Joshua King 6d48f433b7 Review fix: validate engine keys as child ids; every-day three-child walk
Review round 1 (Codex gpt-5.6-terra, 2 LOW): engine snapshot keys now
pass _validate_child_id (True/1.0 compare equal to 1 and would be
accepted then behave differently on lookup); the always-present test
now walks all 28 days asserting all three children's expected verdicts
instead of sampling every third day. Suite: 985 passed.
2026-09-15 17:58:51 -04:00
Joshua King 540f59a353 PresenceEngine with is_present and the always-present default (Feature 05, task 3)
PresenceEngine is an immutable, database-free snapshot of the
children's schedules answering is_present(child_id, date): a child
with NO schedule entry is present every day (the household's
always-present sibling); a scheduled child is present exactly when
the date's weekday is in the pattern for that date's cycle week
(anchor-date arithmetic via cycle_week_index — never ISO parity).
Construction validates the snapshot shape (dict keyed by the
schedule's own child_id, values are PresenceSchedules) and exposes it
read-only (MappingProxyType + __setattr__ refusal). is_present
rejects non-int child ids and non-plain dates.

Tests: the three-child fixture (two complementary rotation children,
one schedule-free) asserted for every day of a four-week span plus
spot-checked swap days; weekend/weekday-set behaviour across weeks;
construction/input rejection matrix; engine immutability. Suite:
985 passed.
2026-09-15 17:57:02 -04:00
dev_agent ee994a3938 Merge pull request 'Implement anchor-date cycle position arithmetic (Feature 05, task 2)' (#58) from task/06cc4782-cycle-arithmetic into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 17:55:21 -04:00
Joshua King 77c4db7b3c Review fix: hardcode independently derived boundary expectations
Review round 1 (Codex gpt-5.6-terra, LOW): the boundary walk asserted
transitions in {(same, +1)} which even a reset/inverting
implementation could satisfy on a two-week cycle. The test now
asserts a hand-verified table of expected indices for every day
around each of the three Dec-31/Jan-1 boundaries (2019/2020/2021,
anchor 2019-01-06), so a parity bug cannot pass by coincidence.
Suite: 980 passed.
2026-09-15 17:54:25 -04:00
Joshua King 5885d5e972 Anchor-date cycle position arithmetic (Feature 05, task 2)
presence.cycle_week_index: ((target_date - anchor_date).days // 7) %
cycle_length_weeks — anchor-date arithmetic ONLY (D-004).  Python
floor division makes pre-anchor dates walk the cycle backwards
(day -1 is the last week), never inverting; the modulo of a negative
stays non-negative, so the index is always a valid week index.  The
target date passes through the model's strict plain-date parsing.
Tests: formula offsets, three-week pattern wrap, backwards walking,
day-by-day stability across three Dec-31 -> Jan-1 boundaries
including 2020 (verified 53 ISO weeks — the parity-inversion
regression), and a source guard that the module never reads ISO week
numbers.  Suite: 980 passed.
2026-09-15 17:51:28 -04:00
dev_agent 7ae4018e59 Merge pull request 'Define the PresenceSchedule model with validation (Feature 05, task 1)' (#57) from task/ede1dc76-presence-model into feature/167e3565-704d-41c7-adf6-3b88ed266f78-presence-custody-schedules 2026-09-15 17:48:35 -04:00
Joshua King cb3c347d61 Review fix: reject datetime anchors, freeze the pattern mapping
Review round 1 (Codex gpt-5.6-terra, 2 MEDIUM): datetime.datetime
subclasses datetime.date and slipped past the anchor check, storing a
time component that would TypeError every later calendar comparison —
rejected explicitly now (both model classes). The frozen schedule's
pattern dict let callers inject unvalidated weekdays or drop required
weeks after construction; the validated mapping is now exposed as a
read-only MappingProxyType over frozenset weeks. Tests: datetime
rejection matrix and immutability assertions. Suite: 974 passed.
2026-09-15 17:46:30 -04:00
Joshua King d76cde1af5 Presence and custody model with total validation (Feature 05, task 1)
presence.py — a PURE module (no HA imports, no DB access, no timezone
logic): PresenceSchedule (child_id, cycle_length_weeks, anchor_date,
pattern mapping week index -> frozenset of present weekdays) and
PresenceOverride (child_id, inclusive start/end range, real-bool
is_present, optional note) with construction that rejects every
invalid shape up front:

- cycle length: integer 1..4 (the 4 cap mirrors schema.py's documented
  CHECK); bools/floats/strings rejected.
- pattern: dict required; week indices validated as non-bool ints
  covering 0..n-1 exactly (gaps and extras rejected); weekday sets
  validated as ints 0..6 (bools rejected — True would silently mean
  Monday; strings rejected — '02' would silently mean {0,2}); an
  explicitly empty week is valid (absent all week — distinct from no
  schedule, the engine's default).
- anchor/override dates: datetime.date or strict YYYY-MM-DD (strptime
  round-trip; '2026-1-5' rejected).
- override range: end >= start; is_present must be a real bool;
  note optional string.
- child ids: non-bool ints (SQLite binds True as 1).

Serialization: encode() emits the database's pipe-separated CSV
(sorted weekdays per week, empty segment for an absent week);
decode() derives the cycle length from the segment count (mirroring
the schema's segment-count CHECK) and validates strictly, so
decode(encode(x)) round-trips losslessly and equal patterns encode
identically. Tests: 59 (construction matrix, round-trips, encode
shapes, override coverage). Suite: 972 passed.
2026-09-15 17:43:09 -04:00
orchestrator-agent b3957ecedf Merge pull request 'Feature 03: Children Registry and Admin Allowlist -> dev' (#56) from feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist into dev 2026-09-15 17:38:44 -04:00
dev_agent c2cdbceb09 Merge pull request 'Implement the fail-closed is_admin resolver (Feature 03, task 5)' (#55) from task/3f2eb268-is-admin-resolver into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 17:36:47 -04:00
Joshua King 7cde256e2a Fail-closed is_admin resolver (Feature 03, task 5)
admin_allowlist.is_admin: True only for an allowlisted HA user id;
False for non-allowlisted, unknown, empty-string, untrimmed variants,
None, non-strings (bools, lists), and an empty allowlist (nobody is
admin rather than everybody). Never raises for malformed input —
malformed means not admin; DB errors propagate for the Feature 09
gate to handle. The last-admin removal refusal (set_admin_ids) is
re-asserted as keeping the verdict sound. Tests: full verdict matrix,
empty-allowlist fail-closed, refusal persistence. Suite: 912 passed.
2026-09-15 17:35:13 -04:00
dev_agent 7a1cef1e54 Merge pull request 'Admin allowlist storage seeded with the setup user (Feature 03, task 4)' (#54) from task/288a953c-admin-allowlist into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 17:33:02 -04:00
Joshua King 913d3658b8 Review fix: omitted picker submissions carry the allowlist forward
Review round 6 (Codex gpt-5.6-terra, MEDIUM): HA replaces entry.options
wholesale, so a submission omitting the picker dropped the allowlist
copy and left only the stale config-flow copy in entry data — a later
database loss would resurrect removed admins. Omitted submissions now
carry the current DB allowlist forward on the options copy. The
legacy-path test updated to assert the carried copy. Suite: 910 passed.
2026-09-15 17:30:29 -04:00
Joshua King 0e4f890ef9 Review fix: reopened-record setup closes the database when seeding fails
Review round 5 (Codex gpt-5.6-terra, MEDIUM): the reopened-runtime
setup path leaked the just-opened connection if owner lookup or
seed_setup_admin raised, so HA's setup retries could accumulate open
handles. Seeding on that path is now wrapped symmetrically with the
first-setup path: any failure closes the database before re-raising,
with a test that fails seeding on a re-setup and asserts the close.
Suite: 909 passed.
2026-09-15 17:26:53 -04:00
Joshua King 7ebd893823 Test fix: malformed owner_ids validated before the seed's early return
The seed validates candidates only when the allowlist is empty; the
test's successful first seed masked the malformed case. The rejection
now runs first. Suite: 908 passed.
2026-09-15 17:23:32 -04:00
Joshua King 068ace5693 Review fix: owner-account fallback seed for first setup
Review round 4 (Codex gpt-5.6-terra, HIGH): relying on the flow
context alone can leave first setup ownerless. seed_setup_admin gains
an owner_ids tier: when no persisted copy and no context user exist,
every HA owner account is seeded (owners can already do everything in
HA — no privilege escalation). __init__ resolves owners via
hass.auth.async_get_users on both setup paths; conftest's hass gains
the auth surface. Tests: owner fallback seeds only owners, context
user still wins over owners, owner_ids argument validation. Suite:
910 passed.
2026-09-15 17:22:39 -04:00
Joshua King f0fa39660c Review fix: duplicate admin selections surface as a field error
Review round 3 (Codex gpt-5.6-terra, LOW): a duplicate raw submission
reached set_admin_ids and crashed the flow with an unhandled
ValueError; the flow now rejects duplicates as invalid_admin with a
test proving the database is untouched. Suite: 905 passed.
2026-09-15 17:16:10 -04:00
Joshua King d90c170460 Review fixes: persisted flow-user admin seed and real cv.multi_select
Review rounds 1-2 (Codex gpt-5.6-terra) found two HIGHs against real HA:
- entry.context is not a durable record — the config flow now persists
  the completing HA user into entry data (CONF_ADMIN_USER_IDS) and the
  seed precedence becomes: options copy (the current list) first, then
  the config flow's entry-data copy, then the context user as a last
  resort. Config-flow tests cover the persisted user and the
  context-free path; a seed-precedence test covers options > data.
- the hand-rolled closure could not serialize as an HA multi-select.
  The harness now mocks homeassistant.helpers.config_validation with a
  faithful multi_select, and the flow uses cv.multi_select exactly as
  real HA code does. Suite: 904 passed.
2026-09-15 17:13:32 -04:00
Joshua King 0d61ea2c88 Admin allowlist storage seeded with the setup user (Feature 03, task 4)
- admin_allowlist.py: list_admin_ids / set_admin_ids (atomic sync of
  the allowlist inside one transaction under the connection lock;
  refuses empty targets so the last admin can never be removed and
  the allowlist can never be emptied — fail closed) and
  seed_setup_admin (seeds ONLY an empty allowlist: stored entry-data
  copy first — disaster recovery for a lost database — else the HA
  user who completed the config flow from the entry context;
  non-empty lists are never resurrected).
- __init__.py: both setup paths (first setup and the reopened-record
  path) seed after migrations; a failed seed closes the database and
  re-raises through the existing error handling.
- options_flow.py: the admin picker is a multi-select of EXISTING HA
  users (hass.auth.async_get_users), defaulted to the current
  database allowlist, membership-checked at the schema level and
  backed up on the raw-input path; empty selections are refused
  (no_admins), unknown ids rejected (invalid_admin); the selection is
  applied to the DB when the runtime record is reachable and always
  stored on the entry as the recovery copy. Submissions without the
  key (legacy raw payloads) keep the three-key behaviour untouched.
- const.CONF_ADMIN_USER_IDS; strings + translations for the field and
  the two errors; conftest entries gain a context dict (HA's entry
  context carries the flow user).
- Tests: 20 allowlist business tests (round-trips, refusal matrix,
  seed precedence and narrowed-list persistence), 5 picker flow tests,
  3 setup-seeding lifecycle tests. Suite: 901 passed.
2026-09-15 17:03:53 -04:00
dev_agent b7f7e092a0 Merge pull request 'Reorder children for panel display order (Feature 03, task 3)' (#53) from task/31e80e1a-child-reorder into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 16:47:44 -04:00
Joshua King 5fe6cb72ab Review fix: strict non-bool int child_id validation on all three calls
Review round 1 (Codex gpt-5.6-terra): SQLite binds bools as integers,
so edit_child(True, ...) would silently edit child 1 and
set_child_active had the same hole (reorder already validated its
list). A shared _validate_child_id guard now runs before lookup in
edit_child, set_child_active and reorder_children's list scan, with a
coercible-id test matrix for both call shapes (True/False/1.0/"1"/
None) asserting the refused call leaves the profile untouched. Suite:
875 passed.
2026-09-15 16:45:36 -04:00
Joshua King 7a585afb9e Reorder children for panel display order (Feature 03, task 3)
- ChildrenDao.reorder now requires a COMPLETE permutation of the
  children table: partial lists (missing an existing child), unknown
  ids and duplicates are rejected with ValueError before any write,
  inside the existing transaction and connection lock (check and
  write atomic). Empty list is the maximally partial reorder against
  a non-empty table; against an empty table it stays a no-op. The two
  DAO tests that encoded the old permissive semantics (silent no-op
  on empty/unknown) are updated to the new contract.
- children.py gains reorder_children: the Feature 09-facing wrapper,
  which additionally rejects non-integer ids up front (bools and
  floats would silently compare against stored integer ids at the SQL
  layer).
- Business tests: round-trip (active list returns new order), partial/
  unknown/duplicate/non-int rejections with order-unchanged proofs,
  empty-list rejection, and inactive children keeping positions in a
  whole-table permutation. Suite: 866 passed.
2026-09-15 16:41:14 -04:00
dev_agent ecffa5bb0e Merge pull request 'Deactivate and reactivate a child without data loss (Feature 03, task 2)' (#52) from task/35e97242-child-deactivate into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 16:37:22 -04:00
Joshua King cfe1609df2 Review fix: set_child_active rejects non-bool is_active
Review round 1 (Codex gpt-5.6-terra): the DAO's int() silently coerced
strings and numerics (1, 0, 1.0) into an unintended active state.
The business layer now requires a real bool, before the existence
check's caller-intent can be lost, with a coercible-input test matrix;
the refused transition leaves the record untouched. Suite: 860 passed.
2026-09-15 16:35:18 -04:00
Joshua King 5c74034f81 Fix leak-guard collision: reword no-delete guard docstring
The guard test's docstring contained the phrase matching the
children/admin_users leak-guard pattern; reworded. Full suite green
before commit this time: 853 passed.
2026-09-15 16:32:18 -04:00
Joshua King 1d247fbca1 Deactivation preserves every referencing row (Feature 03, task 2)
Tests for the no-data-loss deactivation contract over the existing
set_child_active business function: a child with a definition,
instance and completion event keeps all three rows byte-identical
through deactivate and reactivate, is excluded from (and restored to)
the active list, and stays in the full list. A structural guard proves
the no-delete property: no module carries DELETE targeting children
and ChildrenDao exposes no delete-shaped method. Suite: 853 passed.
2026-09-15 16:30:36 -04:00
dev_agent 6108755f0c Merge pull request 'Create and edit child profiles (Feature 03, task 1)' (#51) from task/90a3a7a3-child-profiles into feature/4f802471-1de1-4c35-844f-eebf3bea0de5-children-registry-admin-allowlist 2026-09-15 14:20:24 -04:00
Joshua King d5d972b872 Review fix: serialize create_child's duplicate scan with the insert
Review round 3 (Codex gpt-5.6-terra) found two concurrent creates of
the same normalised name could both pass the duplicate scan before
either INSERT committed, inserting a duplicate with no warning. The
scan and INSERT now run inside the shared connection-scoped lock, so
the second create always sees the first's committed row and warns.
A gated concurrent-create test proves exactly one warning fires; a
sequential-duplicate warning assertion rode along. Suite: 851 passed.
2026-09-15 14:18:33 -04:00
Joshua King 09363a57cf Review fix: race tests read via the DAO, not raw SQL probes
The children/admin_users leak guard forbids children SQL outside the
DAO and its sanctioned test files; the two gated concurrency tests
probed the children table raw. They now assert via ChildrenDao.get.
Suite: 850 passed.
2026-09-15 14:14:56 -04:00
Joshua King 5578507ce8 Review fix: edit_child/set_child_active update+readback atomic
Review round 2 (Codex gpt-5.6-terra) found both functions returned a
readback that a concurrent writer could overwrite between the UPDATE
and the SELECT, so the returned record could be the other caller's
values. Both now run existence-check, duplicate scan, UPDATE and
readback inside the DAOs' shared connection-scoped lock. Two gated
concurrency tests prove the contract deterministically: task A paused
at its UPDATE while task B queues, each returns its own values, final
stored state is the last writer's. Suite: 850 passed.
2026-09-15 14:13:48 -04:00
Joshua King 712527d5ba Review fix: duplicate-name scan excludes only the edited child
Review round 1 (Codex gpt-5.6-terra) found: a case-only edit could
suppress the duplicate-name warning even when a DIFFERENT child
already carried the normalised name, because the edit path compared
only against the child's own old name. The scan now always runs on
the resulting state and excludes only the child being edited
(exclude_child_id), so case-only edits and stay-duplicated renames
warn like any other collision; renaming to your own name stays
warning-free when no other child holds the name. Tests: new
case-only-collision test, stay-duplicated rename assertion, and a
split self-rename test; suite at 848 passed.
2026-09-15 14:08:24 -04:00
Joshua King 61b77cc2db Child-profile business layer (Feature 03, task 1)
Business-layer functions over ChildrenDao for creating and editing
child profiles, so the Feature 09 service gate never touches the DAO
directly.

- children.py: create_child (display name required and trimmed,
  whitespace-only rejected; duplicate display name allowed but logged
  as a warning, case-insensitive and whitespace-normalised on both
  sides), edit_child (_UNSET sentinel for leave-alone; returns the
  updated record; renaming onto a different child's name warns, own
  name does not), set_child_active (the only removal path), and
  list_children (display order). Explicit None for optional fields
  raises instead of silently no-oping: the DAO's update deliberately
  writes only provided fields and cannot express NULL, so a clear
  request must not masquerade as one. UTC second-precision stamps per
  the timestamp policy.
- tests/test_children_registry.py: 27 tests covering the typed
  round-trips, name validation matrix, duplicate-name warning
  behaviour (create and rename, including the rename-to-self
  non-warning), partial edits, refusal of unsupported clears,
  deactivation round-trip and list ordering.
2026-09-15 12:33:39 -04:00
orchestrator-agent a2ea42673c Merge pull request 'Feature 14: Design Model Changes (D-007..D-010) -> dev' (#50) from feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes into dev 2026-09-15 11:23:37 -04:00
dev_agent 82d1be97fa Merge pull request '1.5 Add actor_child_id to completion_events (D-008)' (#49) from task/d77a9756-actor-child-id into feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes 2026-09-15 10:35:46 -04:00
dev_agent 2263481f1e Merge pull request '1.2 Widen instance key to (definition_id, child_id, due_date, window) (D-008)' (#48) from task/26762cd6-quest-instance-key into feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes 2026-09-15 10:35:43 -04:00
dev_agent 522875642d Merge pull request '1.4 Quest windows via quest_definition_windows (D-008)' (#47) from task/c2a8597a-quest-windows into feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes 2026-09-15 10:35:40 -04:00
dev_agent 9eea769c7e Merge pull request '1.3 Multi-assignee definitions via quest_definition_assignees (D-008)' (#46) from task/5cc267f6-quest-assignees into feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes 2026-09-15 10:35:21 -04:00
dev_agent b955ab487b Merge pull request '1.1 Rename task_* to quest_* with legacy-dev migration path (D-007)' (#45) from task/dfd0b9a9-quest-rename into feature/c16267ae-75be-4a4e-b14f-e41c06c8ab11-design-model-changes 2026-09-15 10:34:59 -04:00
Joshua King fe90064b65 Add actor_child_id to completion_events (D-008)
Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column
records which panel profile was tapped; admin events stay NULL.

- schema.py: completion_events table body extracted to
  COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to
  children(id) and the actor-pair coherence is extended to a second
  CHECK: 'user' events require actor_user_id and forbid
  actor_child_id, 'panel' events require actor_child_id and forbid
  actor_user_id.
- migrations.py: migration 6 rebuilds legacy-shaped completion_events
  via the canonical procedure (CHECKs cannot be added to an existing
  table), backfilling each legacy panel row to its own child_id
  (pre-D-008 panel completions were always the tapped child) via
  INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only
  guarantee is untouched; ids preserved; PRAGMA foreign_key_check
  verified in-transaction.
- dao_instances.py: append() takes actor_child_id, validates both
  actor shapes (panel requires it, user forbids it) and the tapped
  child's existence under the connection lock; the record and column
  list grow accordingly.
- tests: coherence CHECK matrix (5 bad shapes), unknown
  actor_child_id FK, panel/user round-trips at DDL and DAO layers,
  migration-6 backfill test on a frozen v2-shape seed; the
  completion_events leak guard gains justified INSERT exemptions for
  the migration layer (UPDATE/DELETE stay forbidden everywhere).
2026-09-15 10:25:50 -04:00
36 changed files with 10339 additions and 175 deletions
+255 -14
View File
@@ -2,21 +2,34 @@
from __future__ import annotations
import asyncio
import datetime
import inspect
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_change
from .const import DOMAIN, LOGGER
from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DOMAIN,
LOGGER,
SERVICE_REGENERATE,
)
from .db import NestQuestDatabase
from .materialize import materialize as _materialize_run
from .migrations import apply_migrations
from .store import async_get_db_path
from .admin_allowlist import seed_setup_admin
_LOGGER = LOGGER
@@ -223,6 +236,135 @@ class NestQuestRuntimeData:
options: dict[str, Any]
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
remove_time_change_listener: Callable[[], Any]
async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
"""Return every Home Assistant owner-account user id.
The last-resort admin seed: when no persisted allowlist copy and no
flow-context user survive to first setup, the HA owner accounts are
seeded so the household is never left with zero admins (an empty
allowlist fails closed, which would lock the household out of its
own integration). Owner accounts can already do everything in HA,
so granting NestQuest admin is not a privilege escalation.
"""
auth = getattr(hass, "auth", None)
if auth is None:
return []
users = await auth.async_get_users()
owners = [
user.id
for user in users
if getattr(user, "is_owner", False) and getattr(user, "id", None)
]
return owners
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
"""Return the (hour, minute) the daily rollover fires at from ``options``.
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
string, so a stored value never needs re-validation here; a missing key
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
"""
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
hour, _, minute = rollover.partition(":")
return int(hour), int(minute)
async def _run_horizon_materialization(
hass: HomeAssistant, database: NestQuestDatabase
) -> None:
"""Materialize the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``.
"today" is computed in HA local time (``hass.config.time_zone``) rather
than the system clock, so the horizon tracks the household's own day.
The walk itself runs through :func:`~.materialize.materialize`, whose DB
access is executor-only.
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic.
"""
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start_date = today.isoformat()
end_date = (
today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
await _materialize_run(database, start_date, end_date, today=today)
def _register_day_rollover_listener(
hass: HomeAssistant,
database: NestQuestDatabase,
options: dict[str, Any],
) -> Callable[[], Any]:
"""Register the daily materialization listener and return its remover.
Fires once per day at the configured local ``day_rollover_time`` and
materializes the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]``
through the shared :func:`_run_horizon_materialization` path.
"""
hour, minute = _day_rollover_hour_minute(options)
async def _run_materialization(_now: datetime.datetime) -> None:
await _run_horizon_materialization(hass, database)
return async_track_time_change(
hass, _run_materialization, hour=hour, minute=minute
)
def _find_live_database(hass: HomeAssistant) -> NestQuestDatabase | None:
"""Return a connected database from the active runtime data, or None.
The domain-global services (e.g. ``regenerate``) have no database of
their own; they resolve whichever config entry's database is currently
live at call time, so none of them can hold a stale handle to a closed
connection after another entry unloads.
"""
for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None)
if database is not None and database.connected:
return database
return None
def _register_regenerate_service(hass: HomeAssistant) -> None:
"""Register ``nestquest.regenerate`` once at domain scope.
The service triggers a full on-demand regeneration over the rolling
horizon, running the SAME idempotent materialize path
(:func:`_run_horizon_materialization`) as the startup backfill and the
daily rollover listener. It is DOMAIN-global — registered exactly once
(guarded by ``has_service``) and NOT bound to any config entry — so the
handler resolves the currently-live database at call time and a name
collision or duplicate setup is impossible. Removal is handled by the
unload path only once the LAST entry is removed (see
:func:`_deregister_regenerate_service`).
"""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
return
async def _regenerate(_call: Any) -> None:
database = _find_live_database(hass)
if database is None:
_LOGGER.warning(
"NestQuest regenerate requested but no config entry has a "
"live database; ignoring"
)
return
await _run_horizon_materialization(hass, database)
hass.services.async_register(DOMAIN, SERVICE_REGENERATE, _regenerate)
def _deregister_regenerate_service(hass: HomeAssistant) -> None:
"""Remove ``nestquest.regenerate`` when the last entry unloads."""
if hass.services.has_service(DOMAIN, SERVICE_REGENERATE):
hass.services.async_remove(DOMAIN, SERVICE_REGENERATE)
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
@@ -241,10 +383,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 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.
# sqlite3 error and never skipped migrations — and the
# same allowlist seeding, so a reopened fresh file is
# never ownerless.
db = await _async_open_database(
hass, await async_get_db_path(hass)
)
try:
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
await seed_setup_admin(
db,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=await _async_owner_user_ids(hass),
)
except BaseException:
# A seeding failure must not leak the just-opened
# connection: close it before the error propagates, so
# HA's setup retry starts from a clean handle (the
# first-setup path closes symmetrically).
await db.close()
raise
existing.database = db
entry.runtime_data = existing
return True
@@ -256,16 +424,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await _async_open_database(hass, await async_get_db_path(hass))
remove_update_listener: Callable[[], Any] | None = None
remove_time_change_listener: Callable[[], Any] | None = None
try:
# Seed the admin allowlist on first setup so the owner is never
# locked out: an empty database takes the persisted admin copy
# — the options flow's saved list first (it is the current
# one), else the config flow's user from entry data — and only
# then the HA user still present on the entry context. No-op
# on a non-empty list (fail-closed narrowing is deliberate).
stored_admin_ids = (
entry.options.get(CONF_ADMIN_USER_IDS)
or entry.data.get(CONF_ADMIN_USER_IDS)
)
context = getattr(entry, "context", None)
context_user_id = (
context.get("user_id")
if isinstance(context, dict)
else None
)
owner_ids = await _async_owner_user_ids(hass)
await seed_setup_admin(
database,
stored_admin_ids=stored_admin_ids,
context_user_id=context_user_id,
owner_ids=owner_ids,
)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, dict(entry.options)
)
# Backfill any days the daily listener missed while the integration
# was off (or freshly installed): run the one idempotent walk over
# [today, today + DEFAULT_HORIZON_DAYS] once at setup. This runs
# through the SAME path as the daily listener and regenerate service.
await _run_horizon_materialization(hass, database)
_register_regenerate_service(hass)
except BaseException:
# A failure anywhere after the database is open must not leak the
# listeners already registered against that (now-closed) database:
# unwind EVERY remover we acquired before the failure, keeping the
# original error as the one raised. Removal is best-effort — a
# failing remover must not mask the setup error that triggered it.
for remove_listener in (
remove_time_change_listener,
remove_update_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException:
pass
await database.close()
raise
assert remove_update_listener is not None
assert remove_time_change_listener is not None
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
database=database,
remove_update_listener=remove_update_listener,
remove_time_change_listener=remove_time_change_listener,
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
@@ -276,28 +498,47 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
domain_data = hass.data.setdefault(DOMAIN, {})
runtime_data = domain_data.pop(entry.entry_id, None)
if not domain_data:
is_last = not domain_data
if is_last:
hass.data.pop(DOMAIN, None)
if runtime_data is None:
runtime_data = getattr(entry, "runtime_data", None)
unload_error: BaseException | None = None
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
unload_error: BaseException | None = None
remove_time_change_listener = getattr(
runtime_data, "remove_time_change_listener", None
)
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
unload_error = err
for remove_listener in (
remove_update_listener,
remove_time_change_listener,
):
if remove_listener is None:
continue
try:
result = remove_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
# Cancel EVERY listener even when an earlier remover
# raises: a skipped remover would leave a daily
# callback firing against a closed database after
# unload. The first error is retained and re-raised
# only after both removers (and the DB close) ran.
if unload_error is None:
unload_error = err
finally:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
if unload_error is not None:
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
raise unload_error
# The ``regenerate`` service is DOMAIN-global: it is torn down only
# once the FINAL entry unloads (``hass.data[DOMAIN]`` is now empty),
# never when a sibling entry is still loaded.
if is_last:
_deregister_regenerate_service(hass)
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
if unload_error is not None:
raise unload_error
return True
@@ -0,0 +1,153 @@
"""Business layer for the admin allowlist (Feature 03).
Sits between the options flow / Feature 09 service gate and the typed
:class:`~.dao_children.AdminUsersDao`. The allowlist stores Home
Assistant user IDs — never usernames or display names (feature
guardrail) — and an empty allowlist must fail closed: every function
here refuses to write an empty allowlist, so the owner can never be
accidentally locked out of admin operations by a bad save.
Shape policy: a user id is a non-empty trimmed string. Duplicate ids
in one write are rejected up front — they would silently map to one
row and hide a caller bug. Writes replace the allowlist atomically:
the diff (additions and removals) is computed and applied inside one
transaction under the connection-scoped lock shared with the DAOs, so
a concurrent reader never observes a half-applied sync.
Seed policy (:func:`seed_setup_admin`): the allowlist is seeded only
when it is EMPTY, so re-running setup never resurrects a deliberately
narrowed list. The setup user (the HA user who completed the config
flow, via the entry context) is added automatically so the owner is
never locked out on first setup; a stored entry-data copy of the list
re-seeds a lost database.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import AdminUsersDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
def _now_stamp() -> str:
"""UTC second-precision stamp, matching the registry's policy."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_user_id(value: object) -> str:
"""Return ``value`` trimmed; raise unless a non-empty string."""
if not isinstance(value, str):
raise ValueError(
f"admin user id must be a string, got {value!r}"
)
trimmed = value.strip()
if not trimmed:
raise ValueError("admin user id must not be empty")
return trimmed
async def list_admin_ids(database: NestQuestDatabase) -> list[str]:
"""Return the allowlisted HA user IDs in storage order."""
return [row.ha_user_id for row in await AdminUsersDao(database).list()]
async def set_admin_ids(
database: NestQuestDatabase, user_ids: list[str]
) -> None:
"""Replace the allowlist with exactly ``user_ids`` (atomic sync).
Refuses an empty list — an empty allowlist must fail closed, and
the only way to reach one through this API would be removing the
last admin, so that removal is refused here with a clear error.
Refuses non-string, empty, or duplicate ids up front. The diff
against the current allowlist (additions and removals) applies
inside one transaction under the connection lock.
"""
if not isinstance(user_ids, list):
raise ValueError("user_ids must be a list of HA user id strings")
normalized: list[str] = []
for value in user_ids:
normalized.append(_validate_user_id(value))
if len(set(normalized)) != len(normalized):
raise ValueError("user_ids must not contain duplicates")
if not normalized:
raise ValueError(
"refusing to empty the admin allowlist: at least one admin "
"must remain (an empty allowlist fails closed)"
)
dao = AdminUsersDao(database)
async with _connection_lock(database):
async with database.transaction():
current = {row.ha_user_id for row in await dao.list()}
target = set(normalized)
for user_id in sorted(current - target):
await dao.remove(user_id)
for user_id in normalized:
await dao.add(user_id, _now_stamp())
async def is_admin(
database: NestQuestDatabase, user_id: object
) -> bool:
"""Resolve an HA user ID against the allowlist — FAILS CLOSED.
True only for an allowlisted user id. Everything else is False:
a non-allowlisted id, an unknown id, a None or non-string value
(a service call with no user context), and an empty allowlist
(nobody is admin rather than everybody). The Feature 09 gate
wraps every admin-only service with this verdict; it never raises
for malformed input — malformed means not admin.
"""
if not isinstance(user_id, str) or not user_id:
return False
return await AdminUsersDao(database).exists(user_id)
async def seed_setup_admin(
database: NestQuestDatabase,
*,
stored_admin_ids: list[str] | None = None,
context_user_id: str | None = None,
owner_ids: list[str] | None = None,
) -> list[str]:
"""Seed an empty allowlist so the owner is never locked out.
Only acts when the allowlist is EMPTY. Preference order: the
stored persisted copy of the list (the options flow's current
selection, then the config flow's original — disaster recovery for
a lost database), then the HA user who completed the config flow
(``context_user_id``), then — when no caller identity survived at
all — every Home Assistant OWNER account (``owner_ids``), so a
first setup whose flow context lost the user id still seeds a
working admin. Returns the allowlist after seeding. Non-empty
allowlists are returned untouched — re-running setup must never
resurrect a deliberately narrowed list.
"""
dao = AdminUsersDao(database)
async with _connection_lock(database):
current = [row.ha_user_id for row in await dao.list()]
if current:
return current
candidates: list[str] = []
if stored_admin_ids is not None:
if not isinstance(stored_admin_ids, list):
raise ValueError(
"stored_admin_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in stored_admin_ids]
elif context_user_id is not None:
candidates = [_validate_user_id(context_user_id)]
elif owner_ids:
if not isinstance(owner_ids, list):
raise ValueError(
"owner_ids must be a list of HA user id strings"
)
candidates = [_validate_user_id(value) for value in owner_ids]
for user_id in candidates:
await dao.add(user_id, _now_stamp())
return [row.ha_user_id for row in await dao.list()]
+280
View File
@@ -0,0 +1,280 @@
"""Business layer for the children registry (Feature 03).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_children.ChildrenDao`: callers get validation, name
policies and the duplicate-name warning here, and never talk to the
DAO or SQL directly. Children are never hard-deleted — deactivation
is a separate explicit operation (:func:`set_child_active`), so
completion history keeps its references (feature guardrail).
Name policy: the display name is required and trimmed; an empty or
whitespace-only name is rejected with ValueError. A duplicate display
name is ALLOWED (two children may legitimately share a nickname) but
logged as a warning so an accidental double-add is visible in the HA
log. The duplicate check is case-insensitive and whitespace-normalised
on both sides, because "ada" and "Ada " are the same name to a parent.
Colour and avatar are optional free-form strings, trimmed when present;
``sort_order`` is an int with the DAO/schema default of 0. Clearing an
optional field back to NULL is deliberately rejected: the DAO's update
writes only provided fields (it cannot express NULL), so the business
layer refuses an explicit ``None`` instead of silently ignoring it.
Timestamps follow the module timestamp policy: the business layer
stamps UTC ISO-8601 at second precision so every row it creates is
uniform.
"""
from __future__ import annotations
import datetime
import logging
from .dao_children import ChildRecord, ChildrenDao, _connection_lock
from .db import NestQuestDatabase
LOGGER = logging.getLogger(__name__)
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in :func:`edit_child`.
_UNSET = object()
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields (whitespace-only names are empty to a parent).
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_sort_order(value: object) -> int:
"""Reject non-int sort orders (bools included) before the DAO."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"sort_order must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def _warn_on_duplicate_name(
database: NestQuestDatabase,
display_name: str,
*,
exclude_child_id: int | None = None,
) -> None:
"""Log a warning when ``display_name`` duplicates an existing child.
Case-insensitive and whitespace-normalised on both sides. The
duplicate is still allowed — this only makes it visible; the log
line names both rows so an accidental double-add can be found.
``exclude_child_id`` removes the child being edited from the scan,
so an edit only warns when the result collides with a DIFFERENT
child's name — including case-only edits, where the self-comparison
would otherwise hide a real duplicate.
"""
lowered = display_name.casefold()
for child in await ChildrenDao(database).list_all():
if exclude_child_id is not None and child.id == exclude_child_id:
continue
if child.display_name.strip().casefold() == lowered:
LOGGER.warning(
"Child display name %r duplicates existing child %d "
"(%r); allowed, but check this was intentional",
display_name,
child.id,
child.display_name,
)
return
async def create_child(
database: NestQuestDatabase,
display_name: str,
*,
colour: str | None = None,
avatar_ref: str | None = None,
sort_order: int = 0,
) -> ChildRecord:
"""Create a child profile and return the stored record.
The display name is required, trimmed, and rejected when empty.
A duplicate display name (case-insensitive) is allowed but logged
as a warning. The scan and the INSERT run inside the
connection-scoped lock shared with the DAOs, so a concurrent
create of the same normalised name queues behind the first insert
and sees it — the duplicate still warns; it can never slip through
unlogged.
"""
name = _validate_text(display_name, "display_name", required=True)
assert name is not None
colour_value = _validate_text(colour, "colour")
avatar_value = _validate_text(avatar_ref, "avatar_ref")
order = _validate_sort_order(sort_order)
async with _connection_lock(database):
await _warn_on_duplicate_name(database, name)
created = await ChildrenDao(database).create(
name,
_now_stamp(),
colour=colour_value,
avatar_ref=avatar_value,
sort_order=order,
)
return created
async def edit_child(
database: NestQuestDatabase,
child_id: int,
*,
display_name: str | object = _UNSET,
colour: str | None | object = _UNSET,
avatar_ref: str | None | object = _UNSET,
sort_order: int | object = _UNSET,
) -> ChildRecord:
"""Edit a child profile and return the updated record.
Arguments default to the module sentinel ``_UNSET`` meaning "leave
this field alone". An explicitly passed ``None`` for an optional
field raises: the DAO's update writes only provided fields and
cannot express NULL, so a silent no-op would mask the caller's
intent (see the module docstring). A provided display name is
validated like create's; the duplicate scan excludes only the child
being edited, so a case-only edit colliding with another child's
normalised name still warns. Raises ValueError when the child does
not exist.
Atomicity: the existence check, the duplicate scan, the UPDATE and
the readback all run inside the connection-scoped lock shared with
the DAOs, so another edit cannot slip between this call's UPDATE
and its readback — the returned record is exactly the one this
edit produced, not a later writer's.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
existing = await dao.get(child_id)
if existing is None:
raise ValueError(f"child {child_id} does not exist")
updates: dict[str, object] = {}
if display_name is not _UNSET:
name = _validate_text(
display_name, "display_name", required=True
)
assert name is not None
await _warn_on_duplicate_name(
database, name, exclude_child_id=child_id
)
updates["display_name"] = name
if colour is not _UNSET:
value = _validate_text(colour, "colour")
if value is None:
raise ValueError(
"clearing colour is not supported; pass a replacement "
"value or omit the field"
)
updates["colour"] = value
if avatar_ref is not _UNSET:
value = _validate_text(avatar_ref, "avatar_ref")
if value is None:
raise ValueError(
"clearing avatar_ref is not supported; pass a "
"replacement value or omit the field"
)
updates["avatar_ref"] = value
if sort_order is not _UNSET:
updates["sort_order"] = _validate_sort_order(sort_order)
if updates:
await dao.update(child_id, **updates)
edited = await dao.get(child_id)
assert edited is not None
return edited
async def set_child_active(
database: NestQuestDatabase, child_id: int, is_active: bool
) -> ChildRecord:
"""Deactivate or reactivate a child; returns the updated record.
Deactivation hides the child from the panel without deleting their
history (feature guardrail); this is the only removal path.
``is_active`` must be a real bool: the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError when the
child does not exist.
Atomicity: existence check, UPDATE and readback run inside the
connection-scoped lock, so a concurrent opposite transition cannot
make this call report the other caller's value.
"""
_validate_child_id(child_id)
async with _connection_lock(database):
dao = ChildrenDao(database)
if await dao.get(child_id) is None:
raise ValueError(f"child {child_id} does not exist")
if not isinstance(is_active, bool):
raise ValueError(
f"is_active must be a real bool, got {is_active!r}"
)
await dao.set_active(child_id, is_active)
updated = await dao.get(child_id)
assert updated is not None
return updated
async def reorder_children(
database: NestQuestDatabase, ordered_ids: list[int]
) -> None:
"""Rewrite the children's panel display order in one transaction.
``ordered_ids`` must be a complete permutation of the children
table — every child id exactly once. A partial list (missing an
existing child), an unknown id, or a duplicate is rejected with
ValueError before any write, so the current order survives a
rejected call. The DAO enforces this inside its own transaction
(check and write are atomic under the connection lock); this
wrapper additionally rejects non-integer ids up front, since
``bool`` and floats would silently compare against stored integer
ids at the SQL layer.
"""
for child_id in ordered_ids:
_validate_child_id(child_id)
await ChildrenDao(database).reorder(ordered_ids)
async def list_children(
database: NestQuestDatabase, *, active_only: bool = False
) -> list[ChildRecord]:
"""Return children in display order (sort_order, then id)."""
dao = ChildrenDao(database)
if active_only:
return await dao.list_active()
return await dao.list_all()
+18 -3
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
from .const import CONF_ADMIN_USER_IDS, DOMAIN
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -28,7 +28,14 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step of the config flow."""
"""Handle the user step of the config flow.
The HA user completing the flow is persisted into the entry
data as the initial admin allowlist copy: real HA does not
guarantee the flow context survives to setup, and entry data is
the persisted record first setup seeds from — the owner is
never locked out.
"""
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
if self._async_in_progress() or self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
@@ -36,4 +43,12 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user")
return self.async_create_entry(title="NestQuest", data=user_input)
data = dict(user_input)
flow_user_id = (
self.context.get("user_id")
if isinstance(self.context, dict)
else None
)
if flow_user_id:
data[CONF_ADMIN_USER_IDS] = [flow_user_id]
return self.async_create_entry(title="NestQuest", data=data)
+9
View File
@@ -9,6 +9,9 @@ LOGGER = logging.getLogger(LOGGER_NAME)
PLATFORMS: list[str] = []
#: Service name for the on-demand regeneration service (``nestquest.regenerate``).
SERVICE_REGENERATE = "regenerate"
SQLITE_DB_FILENAME = "nestquest.db"
# Configuration keys and default values
@@ -21,6 +24,12 @@ DEFAULT_DAY_ROLLOVER_TIME = "00:00"
CONF_PANEL_IDLE_TIMEOUT = "panel_idle_timeout"
DEFAULT_PANEL_IDLE_TIMEOUT = 300
# Admin allowlist (Feature 03): HA user IDs permitted to call admin-only
# services. Stored as IDs, never usernames (feature guardrail). The
# database allowlist is authoritative; the config entry carries the same
# list as a disaster-recovery copy so a lost database can be re-seeded.
CONF_ADMIN_USER_IDS = "admin_user_ids"
# 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
+37 -7
View File
@@ -191,13 +191,20 @@ class ChildrenDao:
async def reorder(self, ordered_ids: list[int]) -> None:
"""Persist a new sort order: ``ordered_ids[i]`` gets sort_order i.
The list must be a COMPLETE permutation of the children table:
every existing child id appears exactly once. Partial lists
(missing an existing child) and unknown ids (naming a child
that does not exist) are rejected with ValueError BEFORE any
write, leaving the current order untouched — a silent partial
reorder would leave unlisted children with stale positions and
hide a caller bug. Duplicate ids are rejected up front: they
cannot all hold distinct positions, and silently assigning the
last occurrence's index would hide a caller bug.
Runs inside one transaction; positions are staged at offset
-len (all negative, so any permutation of the same ids never
collides mid-reorder under the wrapper's single-connection
serialization), then finalized 0..n-1. Duplicate ids are
rejected up front: they cannot all hold distinct positions, and
silently assigning the last occurrence's index would hide a
caller bug.
serialization), then finalized 0..n-1.
Concurrent reorder() calls on one connection are serialized by
a connection-scoped asyncio lock (shared across every ChildrenDao
@@ -205,10 +212,10 @@ class ChildrenDao:
transaction() rejects a second concurrent transaction with
RuntimeError, so racing callers would crash instead of
queueing. The lock makes the second call wait, then run
against the first call's final state.
against the first call's final state — which also keeps the
completeness check honest: the re-read of the table happens
inside the same lock and transaction as the writes.
"""
if not ordered_ids:
return
if len(set(ordered_ids)) != len(ordered_ids):
raise ValueError(
"reorder() received duplicate child ids; every id must "
@@ -216,6 +223,29 @@ class ChildrenDao:
)
async with _connection_lock(self._database):
async with self._database.transaction():
rows = await self._database.fetch_all(
f"SELECT id FROM children"
)
existing = {row[0] for row in rows}
listed = set(ordered_ids)
missing = sorted(existing - listed)
unknown = sorted(listed - existing)
if missing or unknown:
details = []
if missing:
details.append(
f"missing child ids {missing} (a partial "
"reorder would leave them with stale "
"positions)"
)
if unknown:
details.append(
f"unknown child ids {unknown}"
)
raise ValueError(
"reorder() must list every child exactly once; "
+ "; ".join(details)
)
offset = len(ordered_ids)
await self._database.execute_many(
"UPDATE children SET sort_order = ? WHERE id = ?",
+258 -35
View File
@@ -47,6 +47,46 @@ from .db import NestQuestDatabase
_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00"
def _today() -> datetime.date:
"""Return the current local calendar date (the single date source).
Every "today" read in this module goes through this one function so
the delete clamp and the materialize walk's no-past guard read the
same clock, and a caller or test can pin/roll that clock
deterministically (e.g. a queued-across-midnight regression). Pure
clock read — no business rule lives here.
"""
return datetime.date.today()
def _resolve_today(today: datetime.date | None) -> datetime.date:
"""Return the caller-resolved "today", or the host date when absent.
The no-past guard and the delete cutoffs must compare due dates
against the SAME calendar date the materialization horizon was built
from. Home Assistant's configured time zone can lag the host clock
around midnight, so when the caller has already resolved the local
date (``today``) that anchor is authoritative; ``None`` falls back to
the host clock (:func:`_today`), which is exactly the previous
behavior for callers with no HA-local date.
"""
return _today() if today is None else today
def _reject_past(due_date: str, today_iso: str) -> None:
"""Raise unless ``due_date`` is on or after ``today_iso``.
The single no-past rule shared by :meth:`QuestInstancesDao.upsert`
and :meth:`QuestInstancesDao.upsert_if_valid`: instances are never
generated in the past, measured against the resolved "today".
"""
if due_date < today_iso:
raise ValueError(
f"instances are never generated in the past: due_date "
f"{due_date!r} is before today {today_iso!r}"
)
def _validate_utc_timestamp(value: str, field: str) -> None:
"""Raise ValueError unless ``value`` is a strict UTC ISO-8601 stamp.
@@ -106,6 +146,7 @@ class CompletionEventRecord:
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
@@ -115,7 +156,7 @@ _INSTANCE_COLUMNS = (
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"occurred_at, was_on_time"
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
@@ -144,8 +185,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
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]),
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
@@ -164,6 +206,7 @@ class QuestInstancesDao:
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord:
"""Create the dated instance for the widened key idempotently.
@@ -182,6 +225,10 @@ class QuestInstancesDao:
- ``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.
``today`` optionally pins the resolved HA-local date the
guard compares against (falling back to the host clock when
omitted) so a household time zone behind the host around
midnight is not misread as "past".
- ``window`` must be one of ``const.QUEST_WINDOWS``.
- An existing instance WITH a completion event is IMMUTABLE:
the conflict path must not rewrite its snapshot columns
@@ -201,24 +248,20 @@ class QuestInstancesDao:
# 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}"
)
# A caller-supplied ``today`` (HA-local) is used for BOTH
# checks and never re-read, since it is already the anchor the
# batch runs against.
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
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}"
)
# holds across midnight rollovers. A pinned HA-local
# anchor is exempt from the re-read (see above).
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
definition = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions WHERE id = ?",
(definition_id,),
@@ -275,6 +318,116 @@ class QuestInstancesDao:
assert instance is not None
return instance
async def upsert_if_valid(
self,
definition_id: int,
child_id: int,
due_date: str,
generated_at: str,
*,
window: str,
due_time: str | None = None,
today: datetime.date | None = None,
) -> QuestInstanceRecord | None:
"""Insert one dated instance iff its tuple is still materializable.
This is the materialization walk's single write path. Unlike the
generic :meth:`upsert`, which validates only existence and
assignment, this method re-validates EVERY materialization
precondition in the SAME connection-lock-held transaction as the
INSERT — closing the check-then-insert gap a concurrent config
write could otherwise slip through:
- the definition exists AND is active (a definition deactivated
after the input snapshot is skipped, never written);
- the child exists AND is active (a deactivated child is skipped);
- the (definition, child) assignment link still exists (a removed
assignment is skipped);
- the window is still declared on the definition (a removed window
is skipped).
The live checks above ONLY decide whether the tuple is still
valid; they never read the window's ``due_time``. The instance is
written with the caller-supplied ``due_time`` — the value snapshotted
from the coherent input read — so a window-time edit landing
mid-walk cannot leak a newer time into a batch that was already
snapshotted against older rule/presence/assignment decisions.
Returns the stored instance, or ``None`` when a precondition
failed — the tuple no longer materializable, which the walk SKIPS
rather than raising, so the rest of the batch is unaffected. The
no-past rule (instances are never generated in the past) is
preserved from :meth:`upsert` and still raises ValueError; ``today``
optionally pins the HA-local anchor it compares against. An
instance that ALREADY has a completion event is also skipped
(returns ``None``) rather than raised: it is immutable, so the
walk leaves it untouched instead of failing the batch — the
generic :meth:`upsert` keeps its hard refusal for the
delete/regenerate path.
"""
_validate_date(due_date, "due_date")
_validate_window(window)
# Fail fast on obviously-past dates before queuing on the lock;
# the authoritative check re-runs under the lock (see upsert).
today_date = _resolve_today(today)
_reject_past(due_date, today_date.isoformat())
async with _connection_lock(self._database):
async with self._database.transaction():
if today is None:
today_date = _today()
_reject_past(due_date, today_date.isoformat())
still_valid = await self._database.fetch_one(
"SELECT 1 FROM quest_definitions d "
"JOIN quest_definition_assignees a "
"ON a.definition_id = d.id "
"JOIN children c ON c.id = a.child_id "
"JOIN quest_definition_windows w "
"ON w.definition_id = d.id "
"WHERE d.id = ? AND a.child_id = ? AND w.window = ? "
"AND d.is_active = 1 AND c.is_active = 1 LIMIT 1",
(definition_id, child_id, window),
)
if still_valid is None:
# A config change invalidated the tuple since the
# snapshot: skip it rather than abort the batch.
return None
existing = await self._database.fetch_one(
"SELECT 1 FROM completion_events "
"WHERE instance_id = (SELECT id FROM quest_instances "
"WHERE definition_id = ? AND child_id = ? "
"AND due_date = ? AND window = ?)",
(definition_id, child_id, due_date, window),
)
if existing is not None:
# The instance is already completed and therefore
# immutable: the walk SKIPS it (no-op) rather than
# rewriting its snapshot columns or failing the
# batch. This is the materialization walk's skip,
# distinct from the generic :meth:`upsert` refusal.
return None
await self._database.execute(
"INSERT INTO quest_instances (definition_id, child_id, "
"window, due_date, due_time, generated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (definition_id, child_id, due_date, "
"window) DO UPDATE SET "
"due_time = excluded.due_time, "
"generated_at = excluded.generated_at",
(
definition_id,
child_id,
window,
due_date,
due_time,
generated_at,
),
)
instance = await self.get(
definition_id, child_id, due_date, window
)
assert instance is not None
return instance
async def get(
self,
definition_id: int,
@@ -334,7 +487,7 @@ class QuestInstancesDao:
return [_instance_from_row(row) for row in rows]
async def delete_future_uncompleted(
self, definition_id: int, cutoff_date: str
self, definition_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> int:
"""Delete the definition's open instances at/after cutoff.
@@ -346,9 +499,11 @@ class QuestInstancesDao:
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.
sweep (Feature 11) handles past-due instances instead. ``today``
optionally pins the HA-local anchor the clamp compares against
(falling back to the host clock when omitted). 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):
@@ -358,8 +513,8 @@ class QuestInstancesDao:
# 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)
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE definition_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
@@ -370,6 +525,51 @@ class QuestInstancesDao:
)
return result.rowcount
async def delete_future_uncompleted_for_child(
self, child_id: int, cutoff_date: str, *, today: datetime.date | None = None
) -> tuple[int, str]:
"""Delete the child's open instances at/after cutoff, across every
definition, returning ``(deleted, effective_cutoff)``.
Child-scoped counterpart to :meth:`delete_future_uncompleted`,
used when a child's presence changes (Feature 09): 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, for the same reason as the definition-scoped method (see its
docstring). ``today`` optionally pins the HA-local anchor the clamp
compares against (falling back to the host clock when omitted).
Any instance with a completion event is never touched, regardless
of its date.
Returns the number deleted and the effective cutoff that was
actually applied (``max(cutoff_date, today)``, read under the
lock). The caller (:func:`~.materialize.regenerate_for_child`)
passes that effective cutoff back as its re-materialization anchor,
so the delete and the rebuild share ONE execution-day even when the
call is queued across midnight — instead of the caller re-reading a
stale "today" and materializing a window the delete already
superseded.
"""
_validate_date(cutoff_date, "cutoff_date")
async with _connection_lock(self._database):
async with self._database.transaction():
# "today" is read under the lock, matching the
# definition-scoped method: the DELETE executes on this
# date, so a caller queued across midnight cannot delete
# instances that became past while it waited.
today_date = _resolve_today(today)
effective_cutoff = max(cutoff_date, today_date.isoformat())
result = await self._database.execute(
"DELETE FROM quest_instances WHERE child_id = ? "
"AND due_date >= ? AND NOT EXISTS ("
" SELECT 1 FROM completion_events "
" WHERE instance_id = quest_instances.id"
")",
(child_id, effective_cutoff),
)
return result.rowcount, effective_cutoff
class CompletionEventsDao:
"""Typed append-only access to the ``completion_events`` table.
@@ -396,19 +596,21 @@ class CompletionEventsDao:
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | 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.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce 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, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
@@ -428,6 +630,15 @@ class CompletionEventsDao:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_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 "
@@ -458,16 +669,28 @@ class CompletionEventsDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
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 (?, ?, ?, ?, ?, ?, ?)",
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
+88 -1
View File
@@ -186,6 +186,15 @@ class PresenceOverridesDao:
The child must exist (validated atomically under the
connection lock). Date shape is validated strictly, and the
schema CHECK rejects end < start.
Overlap policy (Feature 05): an override for a child may not
overlap another override of the SAME child — two different
present/absent answers for one date are unresolvable. The
overlap check (inclusive boundaries) runs inside the same
transaction as the INSERT, so a racing create cannot slip a
conflicting override between the check and the write; a
conflict raises ValueError naming the conflicting override's
id and range.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
@@ -196,6 +205,29 @@ class PresenceOverridesDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
# Only a well-ordered range has overlap semantics:
# inverted ranges fall through to the INSERT and hit
# the schema's end>=start CHECK, preserving the
# IntegrityError contract the matrix tests rely on.
if end_date >= start_date:
conflicting = await self._database.fetch_one(
f"SELECT {_OVERRIDE_COLUMNS} "
"FROM presence_overrides "
"WHERE child_id = ? AND end_date >= ? "
"AND start_date <= ? ORDER BY id LIMIT 1",
(child_id, start_date, end_date),
)
else:
conflicting = None
if conflicting is not None:
conflict = _override_from_row(conflicting)
raise ValueError(
f"override for child {child_id} "
f"[{start_date}, {end_date}] overlaps existing "
f"override {conflict.id} "
f"[{conflict.start_date}, {conflict.end_date}] "
f"(is_present={conflict.is_present})"
)
result = await self._database.execute(
"INSERT INTO presence_overrides (child_id, "
"start_date, end_date, is_present, note) "
@@ -244,4 +276,59 @@ class PresenceOverridesDao:
"DELETE FROM presence_overrides WHERE id = ?",
(override_id,),
)
return result.rowcount > 0
return result.rowcount > 0
async def read_snapshot_unlocked(
database: NestQuestDatabase,
child_ids: list[int],
range_start: str,
range_end: str,
) -> tuple[
dict[int, PresenceScheduleRecord],
dict[int, list[PresenceOverrideRecord]],
]:
"""Read schedules and overrides for ``child_ids`` WITHOUT locking.
MUST be called inside the connection lock and an open transaction, so
the schedules and overrides returned here stay coherent with every
other table the caller reads inside that same transaction (the
materialization walk's single input snapshot does exactly that). The
date range comes already validated by the caller.
Returns ``(schedules, overrides)``:
- ``schedules`` maps ``child_id`` to its :class:`PresenceScheduleRecord`
for every child that HAS a schedule row. A child absent from the
mapping has no schedule, i.e. is present every day (Feature 05).
- ``overrides`` maps ``child_id`` to that child's overrides overlapping
``[range_start, range_end]`` (inclusive boundaries), ordered by start
date; a child with no matching override is absent from the mapping.
``child_ids`` must name existing children (the caller derives them
from a trusted snapshot, within the same transaction).
"""
children = sorted(set(child_ids))
if not children:
return {}, {}
placeholders = ",".join("?" for _ in children)
schedule_rows = await database.fetch_all(
f"SELECT {_SCHEDULE_COLUMNS} FROM presence_schedules "
f"WHERE child_id IN ({placeholders})",
tuple(children),
)
override_rows = await database.fetch_all(
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides "
f"WHERE child_id IN ({placeholders}) AND end_date >= ? "
"AND start_date <= ? ORDER BY start_date",
tuple(children) + (range_start, range_end),
)
schedules = {
record.child_id: record
for record in map(_schedule_from_row, schedule_rows)
}
overrides: dict[int, list[PresenceOverrideRecord]] = {}
for row in override_rows:
record = _override_from_row(row)
overrides.setdefault(record.child_id, []).append(record)
return schedules, overrides
+606 -17
View File
@@ -40,6 +40,7 @@ from .const import QUEST_WINDOWS
from .dao_children import ChildRecord, _child_from_row, _connection_lock
from .dao_children import _CHILD_COLUMNS
from .db import NestQuestDatabase
from .recurrence import RuleType, RuleValidationError, ScheduleRule
#: Sentinel distinguishing "argument omitted" from "explicit SQL NULL"
#: in update methods: passing ``None`` must mean clearing a nullable
@@ -119,6 +120,26 @@ class ScheduleRuleRecord:
end_date: str | None
@dataclass(frozen=True)
class ScheduleRuleStorage:
"""The eight value columns a ScheduleRule maps to (no id column).
``id`` is assigned by the database on insert, so the model<->storage
mapping concerns only the value columns. ``nth_weekday_weekday`` is
deliberately absent here: for MONTHLY_WEEKDAY its value is folded
into ``weekday_set`` as that column's single CSV element.
"""
rule_type: str
interval: int
weekday_set: str | None
day_of_month: int | None
nth_weekday: int | None
month: int | None
start_date: str
end_date: str | None
@dataclass(frozen=True)
class QuestDefinitionRecord:
"""One row of ``quest_definitions`` (without its assignees)."""
@@ -142,6 +163,24 @@ class QuestDefinitionWindowRecord:
due_time: str | None
@dataclass(frozen=True)
class QuestDefinitionSnapshot:
"""A definition plus its rule, assignees and windows, read atomically.
Returned by :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
and :meth:`QuestDefinitionsDao.edit_definition` with the rule,
assignees and windows read inside the SAME transaction that wrote
them, so the snapshot is a consistent creation/edit-time view — a
concurrent rule, assignee or window mutation cannot change it
between the write and the read.
"""
definition: QuestDefinitionRecord
rule: ScheduleRuleRecord
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
_RULE_COLUMNS = (
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
"month, start_date, end_date"
@@ -188,6 +227,250 @@ def _window_from_row(row: tuple) -> QuestDefinitionWindowRecord:
)
#: Storage columns that are forbidden for each rule type. A populated
#: forbidden column means the row's shape is invalid and must raise on
#: read, never be silently dropped. ``weekday_set`` is absent for
#: DAILY/MONTHLY_DAY/YEARLY; MONTHLY_WEEKDAY is the only shape allowed a
#: non-NULL ``nth_weekday``; YEARLY is the only shape allowed ``month``.
_FORBIDDEN_STORAGE_FIELDS: dict[str, tuple[RuleType, ...]] = {
"weekday_set": (RuleType.DAILY, RuleType.MONTHLY_DAY, RuleType.YEARLY),
"day_of_month": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_WEEKDAY,
),
"nth_weekday": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
RuleType.YEARLY,
),
"month": (
RuleType.DAILY,
RuleType.WEEKLY,
RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY,
RuleType.MONTHLY_WEEKDAY,
),
}
def schedule_rule_to_storage(rule: ScheduleRule) -> ScheduleRuleStorage:
"""Map a validated ScheduleRule onto its storage columns.
``rule_type`` stores as ``RuleType.storage_value`` (MONTHLY_DAY and
MONTHLY_WEEKDAY both store ``'monthly'``); ``weekday_set`` stores as
a sorted CSV list. MONTHLY_WEEKDAY folds its model-only
``nth_weekday_weekday`` into ``weekday_set`` as that list's single
element; every other type leaves the column as its own set
(WEEKLY/CUSTOM_DAYS) or NULL.
"""
shape = rule.rule_type
if shape is RuleType.MONTHLY_WEEKDAY:
weekday_set = str(rule.nth_weekday_weekday)
elif shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
weekday_set = ",".join(str(w) for w in sorted(rule.weekday_set))
else:
weekday_set = None
return ScheduleRuleStorage(
rule_type=shape.storage_value,
interval=rule.interval,
weekday_set=weekday_set,
day_of_month=rule.day_of_month,
nth_weekday=rule.nth_weekday,
month=rule.month,
start_date=rule.start_date,
end_date=rule.end_date,
)
def schedule_rule_from_storage(storage: ScheduleRuleStorage) -> ScheduleRule:
"""Reconstruct a ScheduleRule from storage columns.
Disambiguates MONTHLY_DAY from MONTHLY_WEEKDAY by which field is
populated, and raises RuleValidationError rather than silently
mis-decoding an ambiguous or invalid shape.
"""
rule_type = storage.rule_type
if rule_type == "daily":
shape = RuleType.DAILY
elif rule_type == "weekly":
shape = RuleType.WEEKLY
elif rule_type == "monthly":
shape = _disambiguate_monthly(storage)
elif rule_type == "yearly":
shape = RuleType.YEARLY
elif rule_type == "custom":
shape = RuleType.CUSTOM_DAYS
else:
raise RuleValidationError(
f"unknown storage rule_type {rule_type!r}"
)
_reject_forbidden_storage_fields(storage, shape)
weekday_set = None
nth_weekday_weekday = None
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
weekday_set = _weekdays_from_csv(storage.weekday_set)
elif shape is RuleType.MONTHLY_WEEKDAY:
nth_weekday_weekday = _single_weekday_from_csv(storage.weekday_set)
return ScheduleRule(
rule_type=shape,
interval=storage.interval,
weekday_set=weekday_set,
day_of_month=storage.day_of_month,
nth_weekday=storage.nth_weekday,
nth_weekday_weekday=nth_weekday_weekday,
month=storage.month,
start_date=storage.start_date,
end_date=storage.end_date,
)
def schedule_rule_storage_from_record(
record: ScheduleRuleRecord,
) -> ScheduleRuleStorage:
"""Copy a rule row's value columns into a storage mapping.
``ScheduleRuleRecord`` is the read shape (it carries ``id``);
:func:`schedule_rule_from_storage` consumes the value-only
:class:`ScheduleRuleStorage` shape, so callers re-reading a stored
rule convert through this helper.
"""
return ScheduleRuleStorage(
rule_type=record.rule_type,
interval=record.interval,
weekday_set=record.weekday_set,
day_of_month=record.day_of_month,
nth_weekday=record.nth_weekday,
month=record.month,
start_date=record.start_date,
end_date=record.end_date,
)
def _reject_forbidden_storage_fields(
storage: ScheduleRuleStorage, shape: RuleType
) -> None:
"""Raise unless every populated column is permitted for ``shape``.
The model constructor rejects a forbidden ``day_of_month``,
``nth_weekday``, or ``month``, but ``weekday_set`` never reaches it
(it is folded for MONTHLY_WEEKDAY and dropped otherwise), so a daily
row carrying ``weekday_set="0"`` would otherwise decode as a valid
daily rule and silently lose the stored data. This validates every
shape column explicitly so a populated-forbidden column raises
instead of being discarded.
"""
for field, forbidden_shapes in _FORBIDDEN_STORAGE_FIELDS.items():
value = getattr(storage, field)
if value is not None and shape in forbidden_shapes:
raise RuleValidationError(
f"{shape.value} storage rows must not set {field}, "
f"got {value!r}"
)
def _disambiguate_monthly(storage: ScheduleRuleStorage) -> RuleType:
"""Resolve the shared 'monthly' storage value to a model type.
MONTHLY_DAY names ``day_of_month`` only; MONTHLY_WEEKDAY names
``nth_weekday`` only. Neither, or both, is ambiguous and raises.
"""
day_of_month = storage.day_of_month
nth_weekday = storage.nth_weekday
if day_of_month is not None and nth_weekday is None:
return RuleType.MONTHLY_DAY
if nth_weekday is not None and day_of_month is None:
return RuleType.MONTHLY_WEEKDAY
raise RuleValidationError(
"storage rule_type 'monthly' is ambiguous: must name exactly one "
"of day_of_month or nth_weekday, got "
f"day_of_month={day_of_month!r}, nth_weekday={nth_weekday!r}"
)
def _weekdays_from_csv(value: str | None) -> frozenset[int]:
"""Parse a weekday CSV column into a frozenset of ints.
``None`` and ``""`` both yield an empty frozenset (the caller decides
whether empty is legal); a non-integer token raises
RuleValidationError rather than leaking a raw ValueError.
"""
if value is None or value == "":
return frozenset()
try:
entries = [int(token) for token in value.split(",")]
except ValueError:
raise RuleValidationError(
f"weekday_set must be a CSV of integers, got {value!r}"
) from None
return frozenset(entries)
def _single_weekday_from_csv(value: str | None) -> int:
"""The one weekday a MONTHLY_WEEKDAY stores in weekday_set.
Parses the CSV into tokens FIRST and requires exactly one token,
so a duplicate pair like ``"1,1"`` is rejected rather than silently
canonicalized to a single weekday on re-storage. An empty or
multi-element column raises RuleValidationError.
"""
if value is None or value == "":
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
f"weekday, got {value!r}"
)
tokens = value.split(",")
if len(tokens) != 1:
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must hold exactly one "
f"weekday, got {value!r}"
)
try:
return int(tokens[0])
except ValueError:
raise RuleValidationError(
"MONTHLY_WEEKDAY storage weekday_set must be an integer, "
f"got {value!r}"
) from None
async def _insert_rule_row(
database: NestQuestDatabase, storage: ScheduleRuleStorage
) -> int:
"""Insert one ``schedule_rules`` row and return its new id.
Runs inside the caller's transaction (no lock or transaction of its
own) so a multi-table write can persist the rule together with the
rows that reference it. The caller is responsible for validating
the storage fields — :meth:`ScheduleRulesDao.create` validates the
date shape, and :meth:`QuestDefinitionsDao.create_with_rule_and_windows`
receives storage already mapped from a validated
:class:`~.recurrence.ScheduleRule`.
"""
result = await database.execute(
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
"day_of_month, nth_weekday, month, start_date, end_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
storage.rule_type,
storage.interval,
storage.weekday_set,
storage.day_of_month,
storage.nth_weekday,
storage.month,
storage.start_date,
storage.end_date,
),
)
return result.lastrowid
class ScheduleRulesDao:
"""Typed async access to the ``schedule_rules`` table."""
@@ -218,22 +501,20 @@ class ScheduleRulesDao:
_validate_date(start_date, "start_date")
if end_date is not None:
_validate_date(end_date, "end_date")
result = await self._database.execute(
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
"day_of_month, nth_weekday, month, start_date, end_date) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
rule_type,
interval,
weekday_set,
day_of_month,
nth_weekday,
month,
start_date,
end_date,
rule_id = await _insert_rule_row(
self._database,
ScheduleRuleStorage(
rule_type=rule_type,
interval=interval,
weekday_set=weekday_set,
day_of_month=day_of_month,
nth_weekday=nth_weekday,
month=month,
start_date=start_date,
end_date=end_date,
),
)
rule = await self.get(result.lastrowid)
rule = await self.get(rule_id)
assert rule is not None
return rule
@@ -393,6 +674,191 @@ class QuestDefinitionsDao:
assert definition is not None
return definition
async def create_with_rule_and_windows(
self,
title: str,
schedule_rule: ScheduleRuleStorage,
created_at: str,
assignee_child_ids: list[int],
windows: list[tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> QuestDefinitionSnapshot:
"""Insert a rule, definition, assignees and windows atomically.
The whole write — the schedule rule row, the definition row,
every assignee link and every window declaration — runs inside
ONE transaction under the connection lock, so a validation or
write failure rolls the lot back together (all-or-nothing): the
caller can never observe a definition with a missing rule,
assignee or window, and a rejected create leaves no partial
rows.
Returns a :class:`QuestDefinitionSnapshot` whose assignees and
windows are read back inside the same transaction, so the
returned view cannot be raced by a concurrent assignee/window
mutation after the write.
The rule arrives as pre-validated storage fields (mapped from a
:class:`~.recurrence.ScheduleRule` via
:func:`schedule_rule_to_storage`); its dates are re-validated
here so a hand-built storage can never persist a malformed date.
The definition-level ``due_time`` column is deliberately left
NULL — per-window due times supersede it (D-008). Assignees are
validated as existing-and-active at insert time, and windows are
validated for name and strict HH:MM due time, matching
:meth:`upsert_window`.
"""
_validate_date(schedule_rule.start_date, "start_date")
if schedule_rule.end_date is not None:
_validate_date(schedule_rule.end_date, "end_date")
async with _connection_lock(self._database):
async with self._database.transaction():
for child_id in assignee_child_ids:
await self._validate_assignable_child(child_id)
rule_id = await _insert_rule_row(self._database, schedule_rule)
result = await self._database.execute(
"INSERT INTO quest_definitions (title, description, "
"icon, schedule_rule_id, due_time, is_active, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(title, description, icon, rule_id, None, 1, created_at),
)
definition_id = result.lastrowid
for child_id in assignee_child_ids:
await self._database.execute(
"INSERT INTO quest_definition_assignees "
"(definition_id, child_id) VALUES (?, ?)",
(definition_id, child_id),
)
for window, due_time in windows:
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
definition = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
rule_id
)
assert definition is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
async def edit_definition(
self,
definition_id: int,
*,
title: str | None | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRuleStorage | object = _UNSET,
windows: list[tuple[str, str | None]] | object = _UNSET,
) -> QuestDefinitionSnapshot:
"""Edit a definition's metadata, rule and windows atomically.
Mirrors :meth:`create_with_rule_and_windows`: everything runs
inside ONE transaction under the connection lock, and the
returned snapshot's rule, assignees and windows are read back
inside that transaction. Arguments default to the module sentinel
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
NULL so optional metadata (description, icon) can be cleared.
- ``title``/``description``/``icon`` update the definition row
in place; the definition-level ``due_time`` column is never
written (per-window due times supersede it, D-008).
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
overwrites the referenced ``schedule_rules`` row in place: the
definition keeps its ``schedule_rule_id``, so no new or
orphaned rule row appears.
- ``windows`` REPLACES the whole window set: the current rows
are deleted and the given set re-inserted, so missing windows
are added, changed due times updated and removed windows
dropped.
Assignment is deliberately not settable here (multi-assignee is
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
activation is untouched. Raises ValueError when the definition
does not exist. Edits change future instances only: no
``quest_instances`` or ``completion_events`` row is touched.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definition = await self.get(definition_id)
if definition is None:
raise ValueError(
f"quest definition {definition_id} does not exist"
)
assignments: list[str] = []
parameters: list[object] = []
for column, value in (
("title", title),
("description", description),
("icon", icon),
):
if value is not _UNSET:
assignments.append(f"{column} = ?")
parameters.append(value)
if assignments:
parameters.append(definition_id)
await self._database.execute(
f"UPDATE quest_definitions SET "
f"{', '.join(assignments)} WHERE id = ?",
tuple(parameters),
)
if rule is not _UNSET:
await ScheduleRulesDao(self._database).update(
definition.schedule_rule_id,
rule_type=rule.rule_type,
interval=rule.interval,
weekday_set=rule.weekday_set,
day_of_month=rule.day_of_month,
nth_weekday=rule.nth_weekday,
month=rule.month,
start_date=rule.start_date,
end_date=rule.end_date,
)
if windows is not _UNSET:
await self._database.execute(
"DELETE FROM quest_definition_windows "
"WHERE definition_id = ?",
(definition_id,),
)
for window, due_time in windows:
_validate_window(window)
if due_time is not None:
_validate_time(due_time, "due_time")
await self._database.execute(
"INSERT INTO quest_definition_windows "
"(definition_id, window, due_time) "
"VALUES (?, ?, ?)",
(definition_id, window, due_time),
)
updated = await self.get(definition_id)
assignees = await self.list_assignees(definition_id)
window_records = await self.list_windows(definition_id)
rule_record = await ScheduleRulesDao(self._database).get(
updated.schedule_rule_id
)
assert updated is not None
assert rule_record is not None
return QuestDefinitionSnapshot(
definition=updated,
rule=rule_record,
assignees=assignees,
windows=window_records,
)
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
"""Raise ValueError unless the rule exists.
@@ -441,6 +907,81 @@ class QuestDefinitionsDao:
)
return [_definition_from_row(row) for row in rows]
async def _snapshots_for(
self, definitions: list[QuestDefinitionRecord]
) -> list[QuestDefinitionSnapshot]:
"""Read each definition's rule, assignees and windows.
MUST be called inside the connection lock (and ideally a
transaction) so the definitions handed in and the rows this
reads back cannot be raced by a concurrent edit between list
and bundle. Each rule is read through
:class:`ScheduleRulesDao` on the same connection, so the
snapshot's rule record is the one the definition references AT
the moment of the locked read.
"""
rules_dao = ScheduleRulesDao(self._database)
snapshots: list[QuestDefinitionSnapshot] = []
for definition in definitions:
rule_record = await rules_dao.get(definition.schedule_rule_id)
assert rule_record is not None
snapshots.append(
QuestDefinitionSnapshot(
definition=definition,
rule=rule_record,
assignees=await self.list_assignees(definition.id),
windows=await self.list_windows(definition.id),
)
)
return snapshots
async def list_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition with its rule, assignees and
windows, all read inside one locked transaction.
The definitive list and each definition's linked rows are read
in a single BEGIN..COMMIT span under the connection lock, so a
concurrent atomic edit cannot interleave between the definition
list and the bundle read: the caller receives a coherent
snapshot (each definition's rule, assignees and windows as they
were at one instant), never a mixed old-rule/new-windows state.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
"""Return every active definition's snapshot WITHOUT locking.
Mirrors :meth:`list_snapshots_active` minus the lock and
transaction: the definitions and their rules, assignees and
windows are read on the caller's connection under whatever
transaction the caller is already holding, so they stay coherent
with every OTHER table the caller reads inside that same
transaction. MUST be called inside the connection lock and an
open transaction (the single-transaction input snapshot the
materialization walk uses is that caller).
"""
definitions = await self.list_active()
return await self._snapshots_for(definitions)
async def list_snapshots_by_child(
self, child_id: int
) -> list[QuestDefinitionSnapshot]:
"""Return each definition assigned to ``child_id`` with its rule,
assignees and windows, all read inside one locked transaction.
Mirrors :meth:`list_snapshots_active` but selects by assignment
(``list_by_child`` order: newest first). Locks read-and-bundle
into one transaction so the bundle never mixes one edit's
definition with another's windows.
"""
async with _connection_lock(self._database):
async with self._database.transaction():
definitions = await self.list_by_child(child_id)
return await self._snapshots_for(definitions)
async def update(
self,
definition_id: int,
@@ -496,14 +1037,18 @@ class QuestDefinitionsDao:
async def add_assignee(
self, definition_id: int, child_id: int
) -> None:
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
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
composite primary key's storage-level guarantee. Returns the
definition's assignees read back INSIDE the same transaction
that performed the write, so the returned roster is a
consistent post-assignment snapshot — a concurrent assign or
unassign cannot race it after the write commits. Changes
future instances only: already-generated instances and
completion history are never rewritten here.
"""
@@ -524,6 +1069,7 @@ class QuestDefinitionsDao:
"ON CONFLICT (definition_id, child_id) DO NOTHING",
(definition_id, child_id),
)
return await self.list_assignees(definition_id)
async def remove_assignee(
self, definition_id: int, child_id: int
@@ -658,4 +1204,47 @@ class QuestDefinitionsDao:
if not child[0]:
raise ValueError(
f"child {child_id} is inactive and cannot be assigned"
)
)
async def load_materialization_input(
database: NestQuestDatabase,
range_start: str,
range_end: str,
):
"""Read every materialization input in ONE locked transaction.
Returns ``(snapshots, schedules, overrides)``:
- ``snapshots``: every active definition with its rule, assignees and
windows (the :class:`QuestDefinitionSnapshot` shape).
- ``schedules`` / ``overrides``: the presence schedules and
range-scoped overrides of every assignee child, in
:mod:`.dao_presence`'s record shapes.
The whole read — definitions, rules, assignees, windows, and the
assignees' presence schedules and overrides — runs inside a single
BEGIN..COMMIT span under the connection lock, so a concurrent edit
(definition, assignment, window, or presence) cannot interleave
between the parts and the walk never sees a mixed-time view. Each
table's SQL stays in its own DAO module: the presence portion is
delegated to :mod:`.dao_presence` (a lazy import here, since that
module imports this one).
"""
definitions_dao = QuestDefinitionsDao(database)
async with _connection_lock(database):
async with database.transaction():
snapshots = await definitions_dao.read_snapshots_active()
child_ids = sorted(
{
child.id
for snapshot in snapshots
for child in snapshot.assignees
}
)
from . import dao_presence # lazy: dao_presence imports us
schedules, overrides = await dao_presence.read_snapshot_unlocked(
database, child_ids, range_start, range_end
)
return snapshots, schedules, overrides
+341
View File
@@ -0,0 +1,341 @@
"""The materialization walk (Feature 07 core).
:func:`materialize` turns the schedule rules + custody presence of the
active quest definitions into concrete ``quest_instances`` rows over a
date range. It is the pure walk over definitions x dates x assignees x
windows; HA scheduling (day-rollover triggers) is a separate later
task and lives elsewhere.
The walk is deterministic and idempotent:
- All inputs — the active definitions with their rules, assignees and
windows, AND the assignee children's presence schedules and scoped
overrides — are read inside ONE locked transaction (see
:func:`~.dao_rules.load_materialization_input`), so the walk sees a
single coherent snapshot rather than a definition state from one
instant and a presence state from another.
- Presence is snapshotted into an immutable
:class:`~.presence.PresenceEngine` from that same coherent read.
- For each date in the range, each definition whose decoded rule
:func:`~.recurrence.occurs_on` on that date, each assignee who is
``is_present`` on that date and each declared window, one instance is
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
with a ``generated_at`` stamp shared by the whole batch (one coherent
time per run).
A config change that lands AFTER the snapshot is read but BEFORE a
tuple's write (an assignment removed, a definition or child deactivated,
a window removed) makes that tuple SKIP rather than abort the batch:
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` re-validates
every materialization precondition (active definition, active child,
live assignment, declared window, and its CURRENT ``due_time``) inside
the SAME transaction as the INSERT, so the check and the write cannot be
split by a racing config edit.
The upsert is idempotent on (definition_id, child_id, due_date,
window), so re-running the materialization over the same range never
duplicates a row; a tuple whose instance is already completed is SKIPPED
(never rewritten, never raised) by
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`.
Regeneration: a config or presence change must rebuild future instances
so they track the new state. :func:`regenerate_for_definition` covers
definition edits / reassignment (Features 06/07); :func:`regenerate_for_child`
covers a single child's presence change (Feature 09). Both delete the
affected open instances at or after today — never a completed instance,
never the past — and re-run the walk over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]``.
HOOK (Feature 09 presence services): the presence business layer that
lands in Feature 09 MUST call :func:`regenerate_for_child` after EVERY
presence-state write so a child's future instances track its presence:
``set_presence_pattern`` (including clearing/setting an empty pattern),
deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
``create_presence_override``, and ``delete_presence_override``. A
schedule delete changes presence state too (the child becomes
always-present), so it must regenerate just like the others. This hook
is documented, not yet wired: no presence business layer exists yet, and
the presence DAO (``dao_presence.py``) must stay free of business rules
— never call :func:`regenerate_for_child` from inside the DAO layer.
"""
from __future__ import annotations
import datetime
from .const import DEFAULT_HORIZON_DAYS
from .dao_instances import QuestInstancesDao, _resolve_today
from .dao_rules import (
_validate_date,
load_materialization_input,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
)
from .db import NestQuestDatabase
from .presence import PresenceEngine, PresenceOverride, PresenceSchedule
from .recurrence import ScheduleRule, occurs_on
def _now_stamp() -> str:
"""Return the batch's shared UTC second-precision generation stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _decode_rule(snapshot) -> ScheduleRule:
"""Decode a snapshot's rule record back into a ScheduleRule.
The record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through ``ScheduleRulesDao``) preserves the coherence the snapshot
guarantees.
"""
return schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
def _build_engine(
schedules_records,
overrides_records,
) -> PresenceEngine:
"""Convert raw presence records into an immutable PresenceEngine."""
schedules: dict[int, PresenceSchedule] = {}
for child_id, record in schedules_records.items():
schedules[child_id] = PresenceSchedule.decode(
child_id, record.anchor_date, record.pattern
)
overrides: dict[int, list[PresenceOverride]] = {}
for child_id, records in overrides_records.items():
overrides[child_id] = [
PresenceOverride(
record.child_id,
record.start_date,
record.end_date,
record.is_present,
record.note,
)
for record in records
]
return PresenceEngine(schedules, overrides)
async def materialize(
database: NestQuestDatabase,
start_date: str,
end_date: str,
*,
child_ids: list[int] | None = None,
today: datetime.date | None = None,
) -> int:
"""Generate quest instances for the closed range [start_date, end_date].
``start_date`` and ``end_date`` must be strict ISO calendar dates
(YYYY-MM-DD) with ``end_date`` on or after ``start_date``; anything
else raises ValueError naming the offending field. Returns the number
of instances upserted for the range (idempotent — a re-run refreshes
the same rows, never duplicates them).
``child_ids`` optionally scopes the walk to exactly those children:
assignees outside the list are never written, so a caller that only
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
after a presence change) cannot touch any unrelated child's rows.
When omitted (or None) the walk covers every assignee as before.
``today`` optionally pins the resolved HA-local date the walk's
no-past guard compares against; it is threaded into every
:meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid` write. When
omitted the host clock (``datetime.date.today``) is used, which is the
historical default. A caller that computed the horizon in Home
Assistant's configured time zone MUST pass that same date here so a
time zone behind the host around midnight is not rejected as "past".
``start_date`` is clamped up to the resolved "today" (the same
``today`` anchor threaded into the no-past guard), so a caller that
passes a ``start_date`` already in the past never yields a
past-dated instance: the walk simply begins on today and skips every
earlier date. A range entirely in the past therefore produces zero
instances rather than an error, and the DAO's no-past guard remains
the authoritative backstop under the lock.
All inputs are read in ONE locked transaction; each tuple's write then
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
which re-validates the tuple and its current ``due_time`` in the same
transaction as the INSERT, so a config change that arrives mid-batch
skips the affected tuples rather than aborting the batch or leaving a
partial result.
"""
_validate_date(start_date, "start_date")
_validate_date(end_date, "end_date")
if end_date < start_date:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end_date!r} < {start_date!r}"
)
snapshots, schedules_records, overrides_records = await (
load_materialization_input(database, start_date, end_date)
)
engine = _build_engine(schedules_records, overrides_records)
child_filter = None if child_ids is None else set(child_ids)
definitions = [
(snapshot.definition.id, _decode_rule(snapshot),
snapshot.assignees, snapshot.windows)
for snapshot in snapshots
]
generated_at = _now_stamp()
instances = QuestInstancesDao(database)
start = datetime.datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.datetime.strptime(end_date, "%Y-%m-%d").date()
# Clamp a past start up to today (the HA-local anchor when the caller
# pinned one, else the host clock) so the walk can never emit a
# past-dated instance; the DAO's no-past guard remains the backstop.
# A range entirely in the past therefore yields zero instances.
today_date = _resolve_today(today)
start = max(start, today_date)
count = 0
cursor = start
while cursor <= end:
iso = cursor.isoformat()
for definition_id, rule, assignees, windows in definitions:
if not occurs_on(rule, cursor):
continue
for child in assignees:
if child_filter is not None and child.id not in child_filter:
continue
if not engine.is_present(child.id, cursor):
continue
for window in windows:
written = await instances.upsert_if_valid(
definition_id,
child.id,
iso,
generated_at,
window=window.window,
due_time=window.due_time,
today=today,
)
if written is not None:
count += 1
cursor += datetime.timedelta(days=1)
return count
async def regenerate_for_definition(
database: NestQuestDatabase,
definition_id: int,
*,
today: datetime.date | None = None,
) -> int:
"""Regenerate a definition's future instances after a config change.
The Feature 06 edit/assignment/activation paths call this after a
successful write so future instances track the new state: the
definition's open instances at or after today are deleted (never a
completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` so the (changed) rule,
assignee set and windows re-materialize against today's state.
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — so the delete cutoff and the
re-materialization range share one anchor, and the walk's
``upsert_if_valid`` no-past guard never rejects the regenerated
range. ``today`` optionally pins a caller-resolved HA-local date
(threaded into both the delete cutoff and the re-materialization)
so a household time zone behind the host around midnight stays
consistent. Returns the number of instances the re-materialization
upserted across the whole snapshot (idempotent — other active
definitions' tuples refresh in place, never duplicate).
"""
today_date = _resolve_today(today)
start_date = today_date.isoformat()
end_date = (
today_date + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
).isoformat()
instances = QuestInstancesDao(database)
await instances.delete_future_uncompleted(
definition_id, start_date, today=today
)
return await materialize(database, start_date, end_date, today=today)
# HOOK (Feature 09 presence services): ``regenerate_for_child`` is the
# child-presence counterpart to ``regenerate_for_definition``. The
# presence business layer that lands in Feature 09 MUST call
# ``regenerate_for_child`` after EVERY presence-state write, so future
# instances track a child's changed presence: set_presence_pattern
# (including clearing/setting an empty pattern), deleting/clearing the
# presence schedule (PresenceSchedulesDao.delete),
# create_presence_override, and delete_presence_override. A schedule
# delete changes presence state too (the child becomes always-present),
# so it must regenerate just like the others. It is deliberately NOT
# wired into ``dao_presence.py``: the DAO layer stays pure storage (no
# business rules), and no presence business layer exists yet to host the
# call — see the module docstring note above.
async def regenerate_for_child(
database: NestQuestDatabase,
child_id: int,
*,
today: datetime.date | None = None,
) -> int:
"""Regenerate a child's future instances after a presence change.
The Feature 09 presence services call this after every
presence-state write — set_presence_pattern (including clearing or
setting an empty pattern), deleting/clearing the presence schedule
(:meth:`~.dao_presence.PresenceSchedulesDao.delete`),
create_presence_override, and delete_presence_override — so a
child's future instances track its new presence: the child's open
instances at or after today are deleted across ALL its definitions
(never a completed instance, never the past) via
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
then the materialization walk re-runs over the rolling horizon
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
so the child's now-absent/present dates re-materialize against
today's presence WITHOUT touching any other child's rows.
``child_id`` must be a plain int — bool and float are rejected
(SQLite binds a bool as 0/1 and a float would round, so a malformed
id must never mutate the wrong profile).
"today" is computed the same way the walk computes it —
:func:`~.dao_instances._today` — and the re-materialization range uses the
delete's returned effective cutoff (see
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
so the delete cutoff and the re-materialization window share ONE
execution-day anchor even when the call is queued across midnight.
``today`` optionally pins a caller-resolved HA-local date, threaded
into the delete cutoff and the re-materialization for the same
host-vs-HA time-zone consistency. Returns the number of instances
the re-materialization upserted for the child across the whole
snapshot.
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
today_date = _resolve_today(today)
instances = QuestInstancesDao(database)
# Use the delete's returned effective cutoff as the materialize anchor,
# NOT the independently-computed "today" above. If the call is queued
# across midnight the delete clamps its cutoff to the execution-day;
# materializing from the stale "today" (now yesterday) would trip the
# walk's no-past guard and leave the child's future instances deleted
# but not rebuilt. One anchor for both keeps them consistent.
_, effective_cutoff = await instances.delete_future_uncompleted_for_child(
child_id, today_date.isoformat(), today=today
)
start = datetime.date.fromisoformat(effective_cutoff)
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
return await materialize(
database, start.isoformat(), end_date, child_ids=[child_id], today=today
)
+79 -5
View File
@@ -25,11 +25,12 @@ 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. Migration 4 adds the
windows table and migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key. 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.
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. 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
@@ -73,6 +74,7 @@ from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
@@ -326,6 +328,74 @@ async def _widen_instance_key(
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
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.)
@@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
]
+146 -25
View File
@@ -7,15 +7,19 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
DEFAULT_PANEL_IDLE_TIMEOUT,
DOMAIN,
)
from .admin_allowlist import list_admin_ids, set_admin_ids
MIN_HORIZON_DAYS = 1
MIN_PANEL_IDLE_TIMEOUT = 30
@@ -55,51 +59,168 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
return errors
def _build_schema(current: dict[str, Any]) -> vol.Schema:
"""Build the form schema, pre-filled with the current values."""
return vol.Schema(
{
vol.Required(
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
): int,
vol.Required(
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
): str,
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
}
)
def _build_schema(
current: dict[str, Any],
*,
admin_choices: dict[str, str] | None = None,
admin_default: list[str] | None = None,
) -> vol.Schema:
"""Build the form schema, pre-filled with the current values.
The admin picker is a multi-select of EXISTING Home Assistant users
(never a free-text field — the allowlist stores user IDs); its
default is the current database allowlist, so an untouched submit
re-saves exactly who is already allowed.
"""
fields: dict[Any, Any] = {
vol.Required(
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
): int,
vol.Required(
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
): str,
vol.Required(
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
): int,
}
if admin_choices is not None:
# HA's supported multi-select validator: the frontend can
# serialize the field as a real picker of the given choices.
fields[
vol.Optional(
CONF_ADMIN_USER_IDS, default=list(admin_default or [])
)
] = cv.multi_select(admin_choices)
return vol.Schema(fields)
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
"""Handle the options flow for NestQuest."""
def _async_database(self):
"""Return the runtime database for this entry, or None.
Real HA only opens the options flow for an existing entry, so
the runtime record (and its database) exists; the None branch
covers direct construction in tests and degraded states.
"""
hass_data = getattr(self.hass, "data", None)
if not isinstance(hass_data, dict):
return None
record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id)
database = getattr(record, "database", None)
return database if getattr(database, "connected", False) else None
async def _async_admin_choices(self) -> dict[str, str]:
"""Map HA user id -> display name for the picker.
Real Home Assistant always provides ``hass.auth``; a missing
one (direct construction in tests, degraded harness) degrades
to an empty picker rather than crashing the form.
"""
auth = getattr(self.hass, "auth", None)
if auth is None:
return {}
users = await auth.async_get_users()
return {
user.id: user.name
for user in users
if getattr(user, "id", None)
}
async def _async_current_admin_ids(self) -> list[str]:
"""Return the current allowlist, or [] without a database."""
database = self._async_database()
if database is None:
return []
return await list_admin_ids(database)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Manage the NestQuest options."""
admin_choices = await self._async_admin_choices()
admin_default = await self._async_current_admin_ids()
if user_input is None:
return self.async_show_form(
step_id="init", data_schema=_build_schema(self._current_values())
step_id="init",
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
)
errors = _async_validate(user_input)
# The admin ids are validated only when the key is present:
# schema-validated submits always carry it (vol.Optional's
# default fills from the current allowlist), while raw callers
# omitting the key keep the field untouched.
admin_ids_present = CONF_ADMIN_USER_IDS in user_input
admin_ids = user_input.get(CONF_ADMIN_USER_IDS)
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(self._current_values()),
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
if admin_ids_present and (
not isinstance(admin_ids, list)
or any(not isinstance(value, str) for value in admin_ids)
):
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and not admin_ids:
# An empty allowlist fails closed: saving zero admins is
# refused, never silently applied.
errors[CONF_ADMIN_USER_IDS] = "no_admins"
elif admin_ids_present and any(
value not in admin_choices for value in admin_ids
):
# The schema enforces membership for real HA submits; this
# backs the raw-input path up so an unknown id can never
# reach the allowlist.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
elif admin_ids_present and len(set(admin_ids)) != len(admin_ids):
# set_admin_ids refuses duplicates with ValueError; a flow
# submission must surface that as a field error instead of
# an unhandled crash.
errors[CONF_ADMIN_USER_IDS] = "invalid_admin"
if errors:
return self.async_show_form(
step_id="init",
data_schema=_build_schema(
self._current_values(),
admin_choices=admin_choices,
admin_default=admin_default,
),
errors=errors,
)
return self.async_create_entry(
title="NestQuest",
data={
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
},
)
# Apply the picker's selection to the allowlist when the
# runtime database is reachable; the selection is always
# stored on the entry as the disaster-recovery copy.
database = self._async_database()
if database is not None and admin_ids_present:
await set_admin_ids(database, admin_ids)
data = {
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
}
if admin_ids_present:
data[CONF_ADMIN_USER_IDS] = admin_ids
elif admin_default:
# HA replaces entry.options wholesale, so an omitted picker
# field must carry the CURRENT allowlist forward: dropping
# it would leave only the stale config-flow copy in entry
# data, and a later database loss would resurrect admins
# the owner has since removed.
data[CONF_ADMIN_USER_IDS] = list(admin_default)
return self.async_create_entry(title="NestQuest", data=data)
def _current_values(self) -> dict[str, Any]:
"""Resolve current values from entry options, then entry data, then defaults."""
+477
View File
@@ -0,0 +1,477 @@
"""The presence and custody model (Feature 05).
PURE module: no Home Assistant imports, no database access, no
timezone logic — it operates on plain calendar dates only (feature
guardrail). The later engine (cycle position, ``is_present``,
overrides, previews) builds directly on this model.
A :class:`PresenceSchedule` answers "on which weekdays of which cycle
week is this child at this house?" for an N-week repeating custody
pattern. Two of the household's three children follow a two-week
rotation; a child with NO schedule row is present every day (that
default lives in the engine, not here).
Pattern encoding: the model's canonical serialization is the SAME
pipe-separated CSV the database stores (see schema.py's
``presence_schedules.pattern``): segment ``i`` (week index ``i``) is a
comma-separated list of present weekdays, 0 = Monday .. 6 = Sunday,
e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in week 1. An
empty segment means absent every day of that week. Encoding sorts
each segment's weekdays and uses the exact segment order, so
``decode(encode(schedule))`` round-trips losslessly and two schedules
with equal patterns produce equal strings.
Anchor-date policy: ``anchor_date`` pins the cycle. Cycle position is
computed from anchor-date arithmetic ONLY — never ISO week numbers or
week parity (D-004): 53-week years silently invert parity on January
1st. The arithmetic lives in the engine task; this module validates
that the anchor is a strict calendar date.
Validation policy: construction is total — every reject happens in
``__init__`` so no invalid schedule can ever exist:
- ``cycle_length_weeks`` must be an integer 1..4. The 4 cap matches
the database CHECK (schema.py) and keeps anchor arithmetic and the
encoded pattern human-readable; revisit both together if a longer
cycle is ever needed.
- ``pattern`` must cover week indices 0..cycle_length_weeks-1 exactly
(no gaps, no extra weeks) with sets of integers 0..6 (bools
rejected: True would silently mean Monday).
- ``anchor_date`` must be a strict ISO YYYY-MM-DD calendar date (or a
``datetime.date``).
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from types import MappingProxyType
#: The schema's documented cap for cycle_length_weeks (schema.py keeps
#: the pattern human-readable and the anchor arithmetic small).
MAX_CYCLE_LENGTH_WEEKS = 4
_DATE_FORMAT = "%Y-%m-%d"
def _parse_date(value: object, field_name: str) -> datetime.date:
"""Accept a datetime.date or a strict YYYY-MM-DD string.
``datetime.datetime`` is rejected explicitly: it SUBCLASSES
datetime.date, so a bare isinstance check would store a value with
a time component and every later calendar-date comparison (e.g.
``PresenceOverride.covers``) would raise TypeError.
"""
if isinstance(value, datetime.datetime):
raise ValueError(
f"{field_name} must be a plain calendar date, not a "
f"datetime with a time component: {value!r}"
)
if isinstance(value, datetime.date):
return value
if not isinstance(value, str):
raise ValueError(
f"{field_name} must be a datetime.date or an ISO date string, "
f"got {value!r}"
)
try:
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
except (TypeError, ValueError):
raise ValueError(
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
) from None
if parsed.isoformat() != value:
raise ValueError(
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
)
return parsed
def _validate_week_index(week: object, cycle_length_weeks: int) -> int:
"""Reject non-int week indices (bools included) and out-of-cycle."""
if isinstance(week, bool) or not isinstance(week, int):
raise ValueError(
f"pattern week index must be an integer, got {week!r}"
)
if not 0 <= week < cycle_length_weeks:
raise ValueError(
f"pattern week index must be in 0..{cycle_length_weeks - 1}, "
f"got {week}"
)
return week
def _validate_weekday_set(week: int, weekdays: object) -> frozenset[int]:
"""Validate one week's present-weekday set."""
if isinstance(weekdays, (str, bytes)) or weekdays is None:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
)
try:
members = list(weekdays)
except TypeError:
raise ValueError(
f"pattern[{week}] must be an iterable of weekday ints 0..6, "
f"got {weekdays!r}"
) from None
for member in members:
if isinstance(member, bool) or not isinstance(member, int):
raise ValueError(
f"pattern[{week}] weekday must be an int 0..6, got {member!r}"
)
if not 0 <= member <= 6:
raise ValueError(
f"pattern[{week}] weekday must be in 0..6 (Monday=0), "
f"got {member}"
)
return frozenset(members)
def _validate_cycle_length(value: object) -> int:
"""Reject non-int and out-of-range cycle lengths (bools included)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(
f"cycle_length_weeks must be an integer, got {value!r}"
)
if not 1 <= value <= MAX_CYCLE_LENGTH_WEEKS:
raise ValueError(
f"cycle_length_weeks must be between 1 and "
f"{MAX_CYCLE_LENGTH_WEEKS}, got {value}"
)
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools would silently address child 1)."""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
@dataclass(frozen=True, init=False)
class PresenceSchedule:
"""One child's N-week repeating presence pattern.
``pattern`` maps each week index of the cycle (``0`` .. ``n-1``)
to the set of present weekdays (ints, Monday=0). A week with an
empty set means the child is absent every day of that week — which
is distinct from having no schedule at all (the engine treats a
missing schedule as present every day).
"""
child_id: int
cycle_length_weeks: int
anchor_date: datetime.date
pattern: MappingProxyType # week index -> frozenset of weekdays
def __init__(
self,
child_id: int,
cycle_length_weeks: int,
anchor_date: object,
pattern: dict[int, object],
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
cycle = _validate_cycle_length(cycle_length_weeks)
object.__setattr__(self, "cycle_length_weeks", cycle)
object.__setattr__(
self, "anchor_date", _parse_date(anchor_date, "anchor_date")
)
if not isinstance(pattern, dict):
raise ValueError(
"pattern must map week index to a set of present "
f"weekdays, got {pattern!r}"
)
normalised: dict[int, frozenset[int]] = {}
for week, weekdays in pattern.items():
index = _validate_week_index(week, cycle)
normalised[index] = _validate_weekday_set(index, weekdays)
missing = [
week for week in range(cycle) if week not in normalised
]
if missing:
raise ValueError(
f"pattern must cover week indices 0..{cycle - 1}; "
f"missing {missing}"
)
# Frozen must mean frozen: a bare dict would let callers inject
# unvalidated weekdays (or drop required weeks) AFTER
# construction, bypassing total validation. The mapping
# proxy exposes the validated contents read-only; the
# per-week sets are already frozensets.
object.__setattr__(self, "pattern", MappingProxyType(normalised))
def encode(self) -> str:
"""Serialize to the database's pipe-separated CSV shape.
Segments appear in week order; each segment is the week's
present weekdays as a sorted comma-separated list (an empty set
encodes as an empty segment). Two schedules with equal
patterns encode identically.
"""
segments = [
",".join(str(day) for day in sorted(self.pattern[week]))
for week in range(self.cycle_length_weeks)
]
return "|".join(segments)
@classmethod
def decode(
cls,
child_id: int,
anchor_date: object,
encoded: str,
) -> "PresenceSchedule":
"""Build a schedule from its encoded pattern.
The cycle length is derived from the segment count, mirroring
the database's segment-count CHECK. Decoding is strict: wrong
shapes, non-numeric or out-of-range weekdays are rejected —
the database CHECKs make such rows unrepresentable, and this
is the same contract on the model side. A segment count above
the documented cycle cap is rejected by the ordinary
construction validation.
"""
if not isinstance(encoded, str):
raise ValueError(
f"encoded pattern must be a string, got {encoded!r}"
)
segments = encoded.split("|")
pattern: dict[int, set[int]] = {}
for week, segment in enumerate(segments):
weekdays: set[int] = set()
if segment:
for part in segment.split(","):
if len(part) != 1 or part not in "0123456":
raise ValueError(
f"encoded segment {week} carries an invalid "
f"weekday {part!r} (single digits 0-6 only)"
)
weekdays.add(int(part))
pattern[week] = weekdays
return cls(child_id, len(segments), anchor_date, pattern)
@dataclass(frozen=True, init=False)
class PresenceOverride:
"""A date-specific present/absent flag beating any pattern.
Covers a single date (``end_date == start_date``) or an inclusive
range. A concrete range is the storage shape
(``presence_overrides.end_date`` is NOT NULL), so the model
requires both bounds and validates start <= end. Construction and
validation live here so the engine can trust every instance.
"""
child_id: int
start_date: datetime.date
end_date: datetime.date
is_present: bool
note: str | None
def __init__(
self,
child_id: int,
start_date: object,
end_date: object,
is_present: bool,
note: str | None = None,
) -> None:
object.__setattr__(self, "child_id", _validate_child_id(child_id))
start = _parse_date(start_date, "start_date")
end = _parse_date(end_date, "end_date")
if end < start:
raise ValueError(
f"end_date must be on or after start_date, got "
f"{end.isoformat()} < {start.isoformat()}"
)
object.__setattr__(self, "start_date", start)
object.__setattr__(self, "end_date", end)
if not isinstance(is_present, bool):
raise ValueError(
f"is_present must be a real bool, got {is_present!r}"
)
object.__setattr__(self, "is_present", is_present)
if note is not None and not isinstance(note, str):
raise ValueError(f"note must be a string or None, got {note!r}")
object.__setattr__(self, "note", note)
def covers(self, date: datetime.date) -> bool:
"""Return True when the override applies to ``date``."""
return self.start_date <= date <= self.end_date
def cycle_week_index(
schedule: PresenceSchedule, target_date: object
) -> int:
"""Return the cycle week index ``target_date`` falls in.
Anchor-date arithmetic ONLY (D-004): ``((target_date -
anchor_date).days // 7) % cycle_length_weeks``. Python's floor
division makes dates BEFORE the anchor behave correctly — they
walk the cycle backwards (day -1 is the last week of the cycle,
day -8 wraps one further) instead of inverting, which is exactly
what ISO week parity does wrong in 53-week years. The modulo of
a negative in Python is non-negative, so the index is always a
valid week index.
"""
target = _parse_date(target_date, "target_date")
days = (target - schedule.anchor_date).days
return (days // 7) % schedule.cycle_length_weeks
#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full
#: years of daily checks. A child absent every day (an all-absent
#: pattern with a blocking override) would otherwise loop forever; the
#: cap bounds the scan and surfaces the situation as an error instead.
MAX_PREVIEW_SCAN_DAYS = 366 * 2
class PresenceEngine:
"""Answers "is this child at this house on this date?".
Pure and side-effect-free: built from an immutable snapshot of the
children's schedules (``child_id -> PresenceSchedule``), it never
touches the database — callers build a snapshot from the DAO layer
and pass it in. A child with NO schedule entry is present every
day (the third household child's case); a child WITH a schedule is
present exactly when the date's weekday is in that schedule's
pattern for the date's cycle week index (anchor-date arithmetic —
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
f"{schedules!r}"
)
for child_id, schedule in schedules.items():
# Validate the KEY as a model child id too: True and 1.0
# compare equal to 1, so {True: schedule(1)} would be
# accepted and then behave differently on lookup.
_validate_child_id(child_id)
if not isinstance(schedule, PresenceSchedule):
raise ValueError(
f"schedules[{child_id!r}] must be a PresenceSchedule, "
f"got {schedule!r}"
)
if schedule.child_id != child_id:
raise ValueError(
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.child_id}"
)
# Read-only snapshot: reassignment is refused and no mutator
# exists, so a built engine answers consistently for its
# lifetime. The overrides lists are copied to immutable
# tuples — a shallow mapping copy alone would leave caller-
# owned lists reachable through _overrides, and mutating one
# later would change is_present results.
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
object.__setattr__(
self,
"_overrides",
MappingProxyType(
{child_id: tuple(entries) for child_id, entries in overrides.items()}
),
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
if isinstance(child_id, bool) or not isinstance(child_id, int):
raise ValueError(f"child_id must be an integer, got {child_id!r}")
target = _parse_date(date, "date")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
return True
week = cycle_week_index(schedule, target)
return target.weekday() in schedule.pattern[week]
def next_present_dates(
self,
child_id: object,
start_date: object,
count: int,
) -> list[datetime.date]:
"""Return the next ``count`` dates the child is present.
Walks forward from ``start_date`` (INCLUSIVE — a parent
previewing "starting Monday" expects Monday itself when the
child is present) checking :meth:`is_present` for every day,
so overrides and the pattern are honoured identically. The
admin Schedule tab renders this preview so a parent can confirm
a custody pattern is right before saving it.
A child absent every scanned day raises ValueError after the
two-year search cap — an always-absent child would otherwise
loop forever. ``count`` must be a positive integer.
"""
_validate_child_id(child_id)
start = _parse_date(start_date, "start_date")
if isinstance(count, bool) or not isinstance(count, int):
raise ValueError(f"count must be an integer, got {count!r}")
if count < 1:
raise ValueError(f"count must be at least 1, got {count}")
found: list[datetime.date] = []
day = start
for _ in range(MAX_PREVIEW_SCAN_DAYS):
if self.is_present(child_id, day):
found.append(day)
if len(found) == count:
return found
day += datetime.timedelta(days=1)
raise ValueError(
f"child {child_id} is not present on any of the "
f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; "
f"{count} present day(s) cannot be previewed"
)
def __setattr__(self, name: object, value: object) -> None:
raise AttributeError(
"PresenceEngine is an immutable snapshot; build a new engine "
"instead of mutating this one"
)
@@ -0,0 +1,583 @@
"""Business layer for creating and editing quest definitions (Feature 06).
Sits between the Feature 09 service gate and the typed
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
all-or-nothing create/edit here, and never touch the DAO SQL directly.
:func:`create_quest_definition` is the create path,
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
:func:`unassign_child` the assignment paths, and
:func:`set_quest_definition_active` the deactivate/reactivate path.
There is no permission check here (that is Feature 09).
Validation policy mirrors :mod:`.children`:
- ``title`` is required and trimmed; empty or whitespace-only raises
ValueError.
- ``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
(its constructor is total — rule validation is NOT re-implemented
here).
- ``assignee_child_ids`` must be a non-empty list (strictly a ``list``);
every id is a plain int (bools and floats rejected, since SQLite would
bind them onto a real child) and must reference an existing ACTIVE
child (the DAO enforces the active/exists check inside the same
transaction). Duplicate ids are rejected here, before the composite
primary key could surface them as an :class:`sqlite3.IntegrityError`.
- ``windows`` must be a non-empty list (strictly a ``list``); each entry
is a window name from :data:`~.const.QUEST_WINDOWS` — either a bare
name (no due time) or a ``(name, due_time)`` pair whose due time is a
strict 24-hour HH:MM string. Duplicate window names are rejected
here, before the composite primary key could surface them as an
:class:`sqlite3.IntegrityError`.
The rule, definition, assignees and windows are persisted in ONE
transaction (the DAO's :meth:`~.dao_rules.QuestDefinitionsDao.create_with_rule_and_windows`),
so a rejected or failed create persists nothing. The definition-level
``due_time`` column is superseded by per-window due times (D-008) and is
never set here. ``created_at`` is stamped UTC ISO-8601 at second
precision, matching :mod:`.children`.
"""
from __future__ import annotations
import datetime
from dataclasses import dataclass
from .dao_children import ChildRecord, _connection_lock
from .dao_rules import (
QuestDefinitionRecord,
QuestDefinitionSnapshot,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
ScheduleRuleStorage,
ScheduleRulesDao,
_UNSET,
_validate_date,
_validate_time,
_validate_window,
schedule_rule_from_storage,
schedule_rule_storage_from_record,
schedule_rule_to_storage,
)
from .db import NestQuestDatabase
from .materialize import regenerate_for_definition
from .recurrence import ScheduleRule, occurs_on
@dataclass(frozen=True)
class CreatedQuestDefinition:
"""The persisted result of a create, edit or activation change.
``definition`` is the stored row; ``rule`` is the schedule rule
decoded back through the storage mapping; ``assignees`` and
``windows`` are the definition's linked child records and window
declarations, read through the DAO.
"""
definition: QuestDefinitionRecord
rule: ScheduleRule
assignees: list[ChildRecord]
windows: list[QuestDefinitionWindowRecord]
def _now_stamp() -> str:
"""Return the module's strict UTC second-precision stamp."""
return datetime.datetime.now(datetime.timezone.utc).isoformat(
timespec="seconds"
)
def _validate_text(
value: object, field: str, *, required: bool = False
) -> str | None:
"""Validate a free-text field and return it trimmed.
``None`` raises when ``required`` and otherwise returns None. A
non-string value is rejected; an empty result is rejected for
required fields.
"""
if value is None:
if required:
raise ValueError(f"{field} is required")
return None
if not isinstance(value, str):
raise ValueError(f"{field} must be a string, got {value!r}")
trimmed = value.strip()
if required and not trimmed:
raise ValueError(f"{field} is required")
return trimmed
def _validate_assignee_ids(value: object) -> list[int]:
"""Return the assignee ids as plain ints, or raise naming the field.
The argument must be a non-empty list — strictly a ``list``: tuples
and other sequences are rejected. Each entry must be a real int
(bools and floats rejected) so SQLite cannot bind ``True`` onto
child 1 or a float onto a neighbouring profile.
"""
if type(value) is not list:
raise ValueError(
f"assignee_child_ids must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("assignee_child_ids must not be empty")
ids: list[int] = []
for entry in value:
if isinstance(entry, bool) or not isinstance(entry, int):
raise ValueError(
"assignee_child_ids must contain integer child ids, "
f"got {entry!r}"
)
ids.append(entry)
if len(set(ids)) != len(ids):
raise ValueError(
"assignee_child_ids must not contain duplicate child ids"
)
return ids
def _normalize_windows(
value: object,
) -> list[tuple[str, str | None]]:
"""Normalize the windows argument to ``(name, due_time)`` pairs.
The outer argument must be a non-empty list — strictly a ``list``:
tuples and other sequences are rejected, while each individual entry
may still be a ``(window, due_time)`` tuple. A bare window name
(due time None) is also accepted. The name must be a
:data:`~.const.QUEST_WINDOWS` spelling and a present due time must be
a strict 24-hour HH:MM, both validated up front so a malformed
window is rejected before any write.
"""
if type(value) is not list:
raise ValueError(
f"windows must be a list, got {type(value).__name__}"
)
if not value:
raise ValueError("windows must not be empty")
normalized: list[tuple[str, str | None]] = []
seen: set[str] = set()
for index, entry in enumerate(value):
if isinstance(entry, str):
name: object = entry
due_time: object = None
elif isinstance(entry, (tuple, list)) and len(entry) == 2:
name, due_time = entry[0], entry[1]
else:
raise ValueError(
f"windows[{index}] must be a window name or a "
f"(name, due_time) pair, got {entry!r}"
)
_validate_window(name)
if due_time is not None:
_validate_time(due_time, "due_time")
if name in seen:
raise ValueError(
f"windows must not contain duplicate window {name!r}"
)
seen.add(name)
normalized.append((name, due_time))
return normalized
def _validate_definition_id(value: object) -> int:
"""Reject non-int definition ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address definition 1 and a float would round — malformed service
input must never mutate the wrong definition.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"definition_id must be an integer, got {value!r}")
return value
def _validate_child_id(value: object) -> int:
"""Reject non-int child ids (bools included) before any lookup.
SQLite binds Python bools as integers, so ``True`` would silently
address child 1 and a float would round — malformed service input
must never mutate the wrong profile.
"""
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"child_id must be an integer, got {value!r}")
return value
async def create_quest_definition(
database: NestQuestDatabase,
title: str,
rule: ScheduleRule,
assignee_child_ids: list[int],
windows: list[str | tuple[str, str | None]],
*,
description: str | None = None,
icon: str | None = None,
) -> CreatedQuestDefinition:
"""Create a quest definition (rule + assignees + windows) atomically.
Validates the arguments up front, then persists the schedule rule,
the definition, its assignees and its windows in one transaction via
the DAO. Returns the stored definition together with its decoded
rule, assignees and windows — all read back inside that same
transaction, so the returned bundle is a consistent creation
snapshot. Raises ValueError on any rejected argument; a rejected or
failed create persists nothing.
"""
name = _validate_text(title, "title", required=True)
assert name is not None
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
assignee_ids = _validate_assignee_ids(assignee_child_ids)
window_specs = _normalize_windows(windows)
description_value = _validate_text(description, "description")
icon_value = _validate_text(icon, "icon")
storage = schedule_rule_to_storage(rule)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.create_with_rule_and_windows(
name,
storage,
_now_stamp(),
assignee_ids,
window_specs,
description=description_value,
icon=icon_value,
)
except ValueError as error:
# The DAO's assignee check names only the child; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(
f"assignee_child_ids: {message}"
) from error
raise
decoded_rule = schedule_rule_from_storage(storage)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def edit_quest_definition(
database: NestQuestDatabase,
definition_id: int,
*,
title: str | object = _UNSET,
description: str | None | object = _UNSET,
icon: str | None | object = _UNSET,
rule: ScheduleRule | object = _UNSET,
windows: list[str | tuple[str, str | None]] | object = _UNSET,
today: datetime.date | None = None,
) -> CreatedQuestDefinition:
"""Edit a quest definition's metadata, rule and windows atomically.
Arguments default to the sentinel ``_UNSET`` meaning "leave this
field alone". A provided ``title`` is validated like create's
(required and trimmed); ``description`` and ``icon`` are optional
and may be cleared by passing ``None`` explicitly. A provided
``rule`` must be an already-validated :class:`~.recurrence.ScheduleRule`
and REPLACES the definition's schedule rule IN PLACE — the existing
``schedule_rules`` row is updated, never orphaned or duplicated. A
provided ``windows`` list REPLACES the whole window set (each entry
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
time), matching create's non-empty contract. Assignment is NOT
settable here. Raises ValueError when the definition does not
exist or any argument is rejected.
Returns the updated definition together with its decoded rule,
assignees and windows — all read back inside the DAO's transaction,
so the returned bundle is a consistent edit snapshot. Edits change
future instances only; no ``quest_instances`` or
``completion_events`` row is touched.
``today`` optionally pins the caller-resolved HA-local date the
regeneration's no-past guard and horizon are anchored to (threaded
into :func:`~.materialize.regenerate_for_definition`); it is a plain
``datetime.date`` with no HA import here — the Feature 09 service
supplies the hass-derived value. When omitted, regeneration falls
back to the host clock, same as before.
"""
_validate_definition_id(definition_id)
title_value: str | None | object = _UNSET
if title is not _UNSET:
name = _validate_text(title, "title", required=True)
assert name is not None
title_value = name
description_value: str | None | object = _UNSET
if description is not _UNSET:
description_value = _validate_text(description, "description")
icon_value: str | None | object = _UNSET
if icon is not _UNSET:
icon_value = _validate_text(icon, "icon")
rule_storage: ScheduleRuleStorage | object = _UNSET
if rule is not _UNSET:
if not isinstance(rule, ScheduleRule):
raise ValueError(f"rule must be a ScheduleRule, got {rule!r}")
rule_storage = schedule_rule_to_storage(rule)
window_specs: list[tuple[str, str | None]] | object = _UNSET
if windows is not _UNSET:
window_specs = _normalize_windows(windows)
dao = QuestDefinitionsDao(database)
try:
snapshot = await dao.edit_definition(
definition_id,
title=title_value,
description=description_value,
icon=icon_value,
rule=rule_storage,
windows=window_specs,
)
except ValueError as error:
# The DAO's missing-definition error names only the id; re-raise
# so the public contract names the field the caller passed (matching
# assign/unassign/deactivate).
message = str(error)
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
decoded_rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
await regenerate_for_definition(database, definition_id, today=today)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=decoded_rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def assign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
) -> list[ChildRecord]:
"""Assign ``child_id`` to the definition (idempotent).
Validates that both ids are plain ints (bools and floats rejected)
and that the child exists and is active — the DAO enforces the
active/exists check inside its transaction, and also confirms the
definition exists. Assigning an already-assigned child is a no-op
(no duplicate row). Returns the definition's assignees read back
inside the DAO's same transaction, a consistent post-assignment
roster (a concurrent assign/unassign cannot race it after the
write). Raises ValueError on any rejected argument. Only the
``quest_definition_assignees`` link is written; existing instances
and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the assignment's regeneration (see :func:`edit_quest_definition`).
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
try:
assignees = await dao.add_assignee(definition_id, child_id)
except ValueError as error:
# The DAO names only the child/definition; re-raise so the
# public contract names the field the caller actually passed.
message = str(error)
if message.startswith("child "):
raise ValueError(f"child_id: {message}") from error
if message.startswith("quest definition "):
raise ValueError(f"definition_id: {message}") from error
raise
await regenerate_for_definition(database, definition_id, today=today)
return assignees
async def unassign_child(
database: NestQuestDatabase,
definition_id: int,
child_id: int,
*,
today: datetime.date | None = None,
) -> bool:
"""Unassign ``child_id`` from the definition; True when removed.
Validates both ids are plain ints (bools and floats rejected) and
that the definition exists, then removes the (definition, child)
link. Returns True when a row was removed and False when the child
was not currently assigned. Raises ValueError on a rejected
argument. Only the ``quest_definition_assignees`` link is written;
existing instances and completion history are never touched.
``today`` optionally pins the caller-resolved HA-local date threaded
into the unassignment's regeneration (see :func:`edit_quest_definition`).
"""
_validate_definition_id(definition_id)
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
definition = await dao.get(definition_id)
if definition is None:
raise ValueError(
f"definition_id: quest definition {definition_id} does not exist"
)
removed = await dao.remove_assignee(definition_id, child_id)
await regenerate_for_definition(database, definition_id, today=today)
return removed
async def set_quest_definition_active(
database: NestQuestDatabase,
definition_id: int,
is_active: bool,
*,
today: datetime.date | None = None,
) -> CreatedQuestDefinition:
"""Deactivate or reactivate a definition; returns the updated view.
Flipping the flag is the only removal path (Feature 06 guardrail):
a deactivated definition stops future instance generation but its
row, schedule rule, assignees and windows all survive, and existing
instances plus completion history are never touched. Reactivation
re-enables future generation; generating or removing instances is
Feature 07 and does not happen here.
``definition_id`` must be a plain int (bools and floats rejected,
since SQLite would bind ``True`` onto definition 1) and
``is_active`` must be a real bool — the DAO's ``int()`` would
otherwise silently coerce strings and numerics ("1", 0) into a
state the caller never asked for. Raises ValueError naming the
offending field when the id is malformed, ``is_active`` is not a
bool, or the definition does not exist.
Atomicity: the existence check, the UPDATE and the readback of the
definition, its rule, assignees and windows all run inside the
connection-scoped lock, so the returned bundle is a consistent
snapshot that a concurrent mutation cannot race.
``today`` optionally pins the caller-resolved HA-local date threaded
into the activation change's regeneration (see
:func:`edit_quest_definition`).
"""
_validate_definition_id(definition_id)
if not isinstance(is_active, bool):
raise ValueError(f"is_active must be a real bool, got {is_active!r}")
async with _connection_lock(database):
dao = QuestDefinitionsDao(database)
if await dao.get(definition_id) is None:
raise ValueError(
f"definition_id: quest definition {definition_id} "
"does not exist"
)
await dao.set_active(definition_id, is_active)
updated = await dao.get(definition_id)
assert updated is not None
rule_record = await ScheduleRulesDao(database).get(
updated.schedule_rule_id
)
assert rule_record is not None
assignees = await dao.list_assignees(definition_id)
windows = await dao.list_windows(definition_id)
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(rule_record)
)
await regenerate_for_definition(database, definition_id, today=today)
return CreatedQuestDefinition(
definition=updated,
rule=rule,
assignees=assignees,
windows=windows,
)
def _bundle_snapshot(
snapshot: QuestDefinitionSnapshot,
) -> CreatedQuestDefinition:
"""Decode a DAO snapshot into the layer's rich view.
The rule record carried by the snapshot is the one the definition
referenced at read time, so decoding it here (rather than re-reading
through :class:`~.dao_rules.ScheduleRulesDao`) preserves the
coherence the DAO's locked snapshot guarantees: the rule, assignees
and windows all come from one atomic read, never three separate ones.
"""
rule = schedule_rule_from_storage(
schedule_rule_storage_from_record(snapshot.rule)
)
return CreatedQuestDefinition(
definition=snapshot.definition,
rule=rule,
assignees=snapshot.assignees,
windows=snapshot.windows,
)
async def list_active_definitions(
database: NestQuestDatabase,
) -> list[CreatedQuestDefinition]:
"""Return every ACTIVE definition with its decoded rule and links.
Ordered by rising definition id, matching the DAO's ``list_active``
order, so callers get a stable, repeatable sequence. Each entry is
the same rich :class:`CreatedQuestDefinition` snapshot as the rest of
the layer: the stored row plus its decoded
:class:`~.recurrence.ScheduleRule`, assignees and windows. The
definitions and their links are read by the DAO inside one locked
transaction, so each bundle is a coherent snapshot that a concurrent
edit cannot interleave.
"""
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
async def list_definitions_for_child(
database: NestQuestDatabase,
child_id: int,
) -> list[CreatedQuestDefinition]:
"""Return the definitions assigned to ``child_id`` (rich view).
``child_id`` must be a plain int (bools and floats rejected, since
SQLite would bind ``True`` onto child 1). Results are ordered by
rising definition id, matching ``list_active_definitions`` and
``list_definitions_firing_on``. Each entry is a
:class:`CreatedQuestDefinition` snapshot bundling the definition with
its decoded rule, assignees and windows, read atomically by the DAO.
"""
_validate_child_id(child_id)
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_by_child(child_id)
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return sorted(bundles, key=lambda bundle: bundle.definition.id)
async def list_definitions_firing_on(
database: NestQuestDatabase,
date: str,
) -> list[CreatedQuestDefinition]:
"""Return the ACTIVE definitions whose rule fires on ``date``.
``date`` must be a strict ISO calendar date (YYYY-MM-DD); a malformed
value raises ValueError naming the field. Each active definition's
stored rule is decoded through the task-1 storage mapping and handed
to the recurrence engine's :func:`~.recurrence.occurs_on`; only the
definitions whose decoded rule fires on the date are returned, in
rising definition-id order, each as the same rich
:class:`CreatedQuestDefinition` snapshot as the rest of the layer.
The definitions and their rules are read inside one locked
transaction, so a bundle can never filter on one rule while
presenting another state's windows.
"""
_validate_date(date, "date")
target = datetime.datetime.strptime(date, "%Y-%m-%d").date()
dao = QuestDefinitionsDao(database)
snapshots = await dao.list_snapshots_active()
bundles = [_bundle_snapshot(snapshot) for snapshot in snapshots]
return [bundle for bundle in bundles if occurs_on(bundle.rule, target)]
+41 -27
View File
@@ -159,11 +159,12 @@ self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is already ``child_id``). A coherence
CHECK makes the pair shape non-negotiable: ``'user'`` requires
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
rejected; if an unattended system actor is ever needed, that is a
schema migration, not a silent widening.
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
@@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
""",
]
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),
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')),
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_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')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
+12
View File
@@ -0,0 +1,12 @@
# Service declarations for the NestQuest integration (Home Assistant
# services.yaml metadata). Services are domain-scoped: nested under the
# "nestquest" domain, not repeated per config entry.
regenerate:
name: Regenerate quest instances
description: >-
Regenerate every future quest instance for the household over the rolling
horizon, using the same idempotent materialization as the daily rollover
and the startup backfill. Safe to call at any time: a re-run refreshes the
same rows instead of duplicating them, and completed instances are never
touched.
+6 -3
View File
@@ -22,13 +22,16 @@
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)"
"panel_idle_timeout": "Panel idle timeout (seconds)",
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format."
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
}
@@ -22,13 +22,16 @@
"data": {
"horizon_days": "Generation horizon (days)",
"day_rollover_time": "Day rollover time (HH:MM)",
"panel_idle_timeout": "Panel idle timeout (seconds)"
"panel_idle_timeout": "Panel idle timeout (seconds)",
"admin_user_ids": "Admin users (parents)"
}
}
},
"errors": {
"invalid": "Invalid value.",
"invalid_time": "Time must use 24-hour HH:MM format."
"invalid_time": "Time must use 24-hour HH:MM format.",
"no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.",
"invalid_admin": "Invalid admin selection."
}
}
}
}
+163 -4
View File
@@ -29,7 +29,7 @@ import tempfile
import weakref
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -42,6 +42,9 @@ _HA_MODULES = (
"homeassistant.config_entries",
"homeassistant.data_entry_flow",
"homeassistant.exceptions",
"homeassistant.helpers",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.event",
)
def _find_spec(name: str):
@@ -79,6 +82,9 @@ for _parent, _child in (
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
("homeassistant", "exceptions"),
("homeassistant", "helpers"),
("homeassistant.helpers", "config_validation"),
("homeassistant.helpers", "event"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
@@ -181,6 +187,30 @@ class ConfigEntryNotReady(Exception):
_exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady
import voluptuous as vol
def _multi_select(choices: dict):
"""Mirror homeassistant.helpers.config_validation.multi_select.
The options flow's admin picker uses HA's supported multi-select
validator (schema-serializable by HA's frontend); the mock provides
the same contract: a list whose members are keys of ``choices``.
"""
def _validate(selected):
if not isinstance(selected, list):
raise vol.Invalid("Not a list")
for value in selected:
if value not in choices:
raise vol.Invalid(f"{value} is not a valid option")
return selected
return _validate
_ha_mock("homeassistant.helpers.config_validation").multi_select = _multi_select
# ---------------------------------------------------------------------------
# Shared helpers for the behavior tests.
@@ -214,16 +244,129 @@ class ListenerRegistry:
return sum(len(listeners) for listeners in self._listeners.values())
class TimeChangeRegistry:
"""Models HA's time-change listener bookkeeping.
``track`` mirrors ``homeassistant.helpers.event.async_track_time_change``:
each call appends a record (action + hour/minute/second) and returns
a remove callable that reverses the registration. Tests inspect
``registrations`` and exercise the newest action through :meth:`fire`.
"""
def __init__(self, hass=None):
self._hass = hass
self._registrations: list[dict] = []
def track(self, action, hour=None, minute=None, second=None):
record = {
"action": action,
"hour": hour,
"minute": minute,
"second": second,
}
def _remove():
self._registrations.remove(record)
record["remove"] = _remove
self._registrations.append(record)
return _remove
@property
def registrations(self):
"""Return a copy of the live registrations."""
return list(self._registrations)
@property
def size(self) -> int:
"""Number of live registrations."""
return len(self._registrations)
async def fire(self, now=None, index: int = -1):
"""Run a registered action as HA's scheduler would, awaiting coroutines."""
result = self._registrations[index]["action"](now)
if inspect.isawaitable(result):
await result
def _async_track_time_change(hass, action, hour=None, minute=None, second=None):
"""Mirror homeassistant.helpers.event.async_track_time_change.
Exact HA 2024.6 signature — no ``local`` keyword: the local-time
wrapper is already this function. Routes to the registry attached to
``hass`` (created on demand), so tests reach the registrations via
``hass.time_change``.
"""
registry = getattr(hass, "time_change", None)
if registry is None:
registry = TimeChangeRegistry(hass)
hass.time_change = registry
return registry.track(action, hour=hour, minute=minute, second=second)
_ha_mock("homeassistant.helpers.event").async_track_time_change = _async_track_time_change
class ServiceRegistry:
"""Models HA's ``hass.services`` service registry.
``async_register`` stores a handler under its (domain, service) key,
``has_service`` reports registration, and ``async_remove`` removes it
— exactly the surface Home Assistant's ``ServiceRegistry`` exposes
(there is deliberately NO ``async_unregister`` alias here, so a caller
that uses the wrong method name fails instead of being silently
papered over). ``call`` invokes a stored handler the way HA's service
bus would — awaiting a coroutine result — so tests can exercise a
registered service end-to-end.
"""
def __init__(self, hass=None):
self._hass = hass
self._services: dict[tuple[str, str], object] = {}
def async_register(self, domain, service, func, schema=None, supports_response=None):
self._services[(domain, service)] = func
return None
def has_service(self, domain, service):
return (domain, service) in self._services
def async_remove(self, domain, service):
self._services.pop((domain, service), None)
return None
async def call(self, domain, service, data=None):
func = self._services.get((domain, service))
if func is None:
raise KeyError(f"service {domain}.{service} not registered")
call = SimpleNamespace(domain=domain, service=service, data=data or {})
result = func(call)
if inspect.isawaitable(result):
await result
return result
@property
def registered_services(self):
"""The registered (domain, service) pairs."""
return list(self._services)
def make_config_entry(
entry_id: str = "test_entry",
options: dict | None = None,
data: dict | None = None,
context: dict | None = None,
):
"""Create a stub config entry shaped like HA's ConfigEntry for tests."""
"""Create a stub config entry shaped like HA's ConfigEntry for tests.
``context`` mirrors HA's entry context (the flow that created the
entry carries the acting HA user under ``context["user_id"]``).
"""
entry = SimpleNamespace(
entry_id=entry_id,
options=dict(options or {}),
data=dict(data or {}),
context=dict(context or {}),
)
entry.add_update_listener = None
return entry
@@ -247,6 +390,10 @@ def make_hass() -> tuple:
config=MagicMock(),
config_entries=SimpleNamespace(async_reload=None),
)
# hass.auth mirrors the real surface the integration reads: an
# async_get_users list the tests can repoint at will (the setup
# path resolves HA owner accounts as the last-resort admin seed).
hass.auth = SimpleNamespace(async_get_users=AsyncMock(return_value=[]))
def _cleanup_config_dir():
import shutil
@@ -265,6 +412,11 @@ def make_hass() -> tuple:
hass.async_add_executor_job = _run_executor_job
registry = ListenerRegistry(hass)
hass.time_change = TimeChangeRegistry(hass)
hass.services = ServiceRegistry(hass)
# A valid IANA time zone (the day-rollover listener reads it to
# compute "today" in HA local time).
hass.config.time_zone = "UTC"
async def _async_reload(entry_id: str) -> None:
registry.reloaded.append(entry_id)
@@ -302,8 +454,15 @@ async def hass():
def make_entry():
"""Provide the config-entry factory as a fixture."""
def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
return make_config_entry(entry_id=entry_id, options=options, data=data)
def _factory(
entry_id: str = "test_entry",
options: dict | None = None,
data: dict | None = None,
context: dict | None = None,
):
return make_config_entry(
entry_id=entry_id, options=options, data=data, context=context
)
return _factory
+471
View File
@@ -0,0 +1,471 @@
"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest import async_setup_entry, async_unload_entry
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
seed_setup_admin,
set_admin_ids,
)
from custom_components.nestquest.dao_children import AdminUsersDao
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 _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / name)
try:
await apply_migrations(database)
return await body(database)
finally:
await database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# set_admin_ids: read/write with the fail-closed guarantee
# ---------------------------------------------------------------------------
def test_set_admin_ids_round_trip(tmp_path) -> None:
async def _body(database):
await set_admin_ids(database, ["user-owner", "user-parent"])
# Membership is the contract; AdminUsersDao.list orders by id.
assert sorted(await list_admin_ids(database)) == [
"user-owner",
"user-parent",
]
# Replace: the removed id is gone, the new one present.
await set_admin_ids(database, ["user-parent", "user-grandma"])
assert sorted(await list_admin_ids(database)) == [
"user-grandma",
"user-parent",
]
return await list_admin_ids(database)
_with_db(tmp_path, "set-round-trip.db")(_body)
def test_set_admin_ids_rejects_empty_allowlist(tmp_path) -> None:
"""Removing the last admin (or saving an empty selection) is
refused with a clear error: an empty allowlist fails closed, so
the owner must never be able to reach one through this API."""
async def _body(database):
await set_admin_ids(database, ["user-owner"])
with pytest.raises(ValueError, match="at least one admin"):
await set_admin_ids(database, [])
# The refused write left the allowlist intact.
assert await list_admin_ids(database) == ["user-owner"]
return None
_with_db(tmp_path, "set-empty-refused.db")(_body)
@pytest.mark.parametrize(
"bad", [None, "notalist", [42], [None], [""], [" "], 5]
)
def test_set_admin_ids_rejects_malformed_input(tmp_path, bad) -> None:
async def _body(database):
with pytest.raises(ValueError):
await set_admin_ids(database, bad)
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "set-malformed.db")(_body)
def test_set_admin_ids_rejects_duplicates(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError, match="duplicates"):
await set_admin_ids(database, ["user-a", "user-a"])
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "set-duplicates.db")(_body)
def test_set_admin_ids_trims_and_replaces_atomically(tmp_path) -> None:
async def _body(database):
await set_admin_ids(database, [" user-a "])
assert await list_admin_ids(database) == ["user-a"]
# Sync that both adds and removes in one write.
await set_admin_ids(database, ["user-b", "user-c"])
assert await list_admin_ids(database) == ["user-b", "user-c"]
return None
_with_db(tmp_path, "set-trim.db")(_body)
# ---------------------------------------------------------------------------
# seed_setup_admin: the owner is never locked out on first setup
# ---------------------------------------------------------------------------
def test_seed_adds_context_user_on_first_setup(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database, stored_admin_ids=None, context_user_id="owner-1"
)
assert seeded == ["owner-1"]
assert await list_admin_ids(database) == ["owner-1"]
return seeded
_with_db(tmp_path, "seed-context.db")(_body)
def test_seed_prefers_stored_entry_ids_over_context(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database,
stored_admin_ids=["user-a", "user-b"],
context_user_id="owner-1",
)
assert seeded == ["user-a", "user-b"]
return seeded
_with_db(tmp_path, "seed-stored.db")(_body)
def test_seed_leaves_nonempty_allowlist_untouched(tmp_path) -> None:
"""Re-running setup must never resurrect a deliberately narrowed
list: only an EMPTY allowlist is seeded."""
async def _body(database):
await set_admin_ids(database, ["user-kept"])
seeded = await seed_setup_admin(
database,
stored_admin_ids=["user-a", "user-b"],
context_user_id="owner-1",
)
assert seeded == ["user-kept"]
assert await list_admin_ids(database) == ["user-kept"]
return seeded
_with_db(tmp_path, "seed-narrowed.db")(_body)
def test_seed_with_nothing_available_leaves_empty(tmp_path) -> None:
async def _body(database):
seeded = await seed_setup_admin(
database, stored_admin_ids=None, context_user_id=None
)
assert seeded == []
return seeded
_with_db(tmp_path, "seed-nothing.db")(_body)
def test_seed_rejects_malformed_candidates(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError):
await seed_setup_admin(
database, stored_admin_ids=[42], context_user_id=None
)
with pytest.raises(ValueError):
await seed_setup_admin(
database, stored_admin_ids=None, context_user_id=" "
)
assert await list_admin_ids(database) == []
return None
_with_db(tmp_path, "seed-malformed.db")(_body)
# ---------------------------------------------------------------------------
# Setup wiring: async_setup_entry seeds automatically
# ---------------------------------------------------------------------------
async def _setup_entry(hass, entry, registry):
from tests.test_lifecycle import _wire
entry = _wire(entry, registry)
await async_setup_entry(hass, entry)
return entry
async def test_setup_seeds_allowlist_from_entry_context(
hass, make_entry
) -> None:
entry = await _setup_entry(
hass,
make_entry(context={"user_id": "ha-owner"}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["ha-owner"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_seeds_allowlist_from_stored_entry_data(
hass, make_entry
) -> None:
"""A lost database re-seeded from the entry's stored admin copy."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass,
make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["user-a", "user-b"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_without_context_leaves_allowlist_empty(
hass, make_entry
) -> None:
"""Fail closed: no context user and no stored copy means an empty
allowlist — nobody is admin until the owner configures it."""
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == []
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_does_not_reseed_narrowed_allowlist(
hass, make_entry
) -> None:
from custom_components.nestquest import async_unload_entry
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database = entry.runtime_data.database
await set_admin_ids(database, ["user-parent"])
await database.close()
assert await async_unload_entry(hass, entry) is True
# Reload with the SAME context: the narrowed list survives.
entry_reloaded = await _setup_entry(
hass, make_entry(context={"user_id": "ha-owner"}), hass.registry
)
database_b = entry_reloaded.runtime_data.database
try:
assert await list_admin_ids(database_b) == ["user-parent"]
finally:
await database.close()
assert await async_unload_entry(hass, entry_reloaded) is True
async def test_setup_seed_prefers_options_over_entry_data(
hass, make_entry
) -> None:
"""Seed precedence on a fresh database: the options flow's saved
list (the current one) beats the config flow's original copy."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
entry = await _setup_entry(
hass,
make_entry(
options={CONF_ADMIN_USER_IDS: ["user-current"]},
data={CONF_ADMIN_USER_IDS: ["ha-owner"]},
context={"user_id": "ha-owner"},
),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["user-current"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_owner_fallback_seeds_owner_accounts(
hass, make_entry
) -> None:
"""Last-resort seed: with no persisted copy and no flow-context
user, HA owner accounts are seeded so first setup is never
ownerless (the real flow may lose the context; owners can already
do everything in HA)."""
from unittest.mock import AsyncMock
hass.auth.async_get_users = AsyncMock(
return_value=[
SimpleNamespace(id="owner-1", is_owner=True),
SimpleNamespace(id="member-1", is_owner=False),
SimpleNamespace(id="owner-2", is_owner=True),
]
)
entry = await _setup_entry(hass, make_entry(), hass.registry)
database = entry.runtime_data.database
try:
assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
async def test_setup_owner_fallback_not_used_when_context_present(
hass, make_entry
) -> None:
"""The context user (flow caller) wins over the owner fallback:
a non-owner parent configuring the integration becomes the admin."""
from unittest.mock import AsyncMock
hass.auth.async_get_users = AsyncMock(
return_value=[SimpleNamespace(id="owner-1", is_owner=True)]
)
entry = await _setup_entry(
hass,
make_entry(context={"user_id": "parent-configured"}),
hass.registry,
)
database = entry.runtime_data.database
try:
assert await list_admin_ids(database) == ["parent-configured"]
finally:
await database.close()
assert await async_unload_entry(hass, entry) is True
def test_seed_owner_ids_argument(tmp_path) -> None:
async def _body(database):
# Malformed owner lists are rejected like any other candidate —
# while the allowlist is still empty, so the seed proceeds far
# enough to validate.
with pytest.raises(ValueError):
await seed_setup_admin(
database,
stored_admin_ids=None,
context_user_id=None,
owner_ids=[7],
)
assert await list_admin_ids(database) == []
seeded = await seed_setup_admin(
database,
stored_admin_ids=None,
context_user_id=None,
owner_ids=["owner-1"],
)
assert seeded == ["owner-1"]
return seeded
_with_db(tmp_path, "seed-owner-arg.db")(_body)
async def test_reopened_setup_failure_closes_database(
hass, make_entry, monkeypatch
) -> None:
"""A seeding failure on the reopened-record path closes the just-
opened connection before re-raising (no leaked handles across HA's
setup retries)."""
import pytest as pytest_module
from unittest.mock import AsyncMock
import custom_components.nestquest as nestquest_module
from custom_components.nestquest.db import NestQuestDatabase
# First: a successful setup to create the runtime record and file.
entry = await _setup_entry(hass, make_entry(), hass.registry)
await async_unload_entry(hass, entry)
# Now a re-setup whose seeding fails after the reopen.
async def _failing_seed(database, **kwargs):
raise RuntimeError("seed boom")
monkeypatch.setattr(
nestquest_module, "seed_setup_admin", _failing_seed, raising=True
)
close_calls: list[bool] = []
original_close = NestQuestDatabase.close
async def _spy_close(self):
close_calls.append(True)
return await original_close(self)
monkeypatch.setattr(NestQuestDatabase, "close", _spy_close)
with pytest_module.raises(RuntimeError, match="seed boom"):
await _setup_entry(hass, entry, hass.registry)
# The reopen path opened AND closed a connection; the failure is
# the last thing observed.
assert close_calls[-1] is True
# ---------------------------------------------------------------------------
# is_admin: the fail-closed resolver (Feature 09's verdict function)
# ---------------------------------------------------------------------------
def test_is_admin_verdict_matrix(tmp_path) -> None:
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
assert await is_admin(database, "user-owner") is True
assert await is_admin(database, "user-other") is False
# Unknown, malformed, and absent identities all fail closed.
assert await is_admin(database, "") is False
assert await is_admin(database, " user-owner ") is False, (
"ids are stored trimmed; an untrimmed lookup is a different "
"id and must not match"
)
assert await is_admin(database, None) is False
assert await is_admin(database, 42) is False
assert await is_admin(database, ["user-owner"]) is False
assert await is_admin(database, True) is False
return None
_with_db(tmp_path, "resolver-matrix.db")(_body)
def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None:
"""An empty allowlist returns False for EVERYONE, not True."""
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
for candidate in ("user-owner", "", None, 1):
assert await is_admin(database, candidate) is False
return None
_with_db(tmp_path, "resolver-empty.db")(_body)
def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None:
"""The refusal keeps the verdict sound: a refused removal leaves
the admin in place, so is_admin still resolves True."""
async def _body(database):
from custom_components.nestquest.admin_allowlist import is_admin
await set_admin_ids(database, ["user-owner"])
with pytest.raises(ValueError, match="at least one admin"):
await set_admin_ids(database, [])
assert await is_admin(database, "user-owner") is True
return None
_with_db(tmp_path, "resolver-refusal.db")(_body)
+842
View File
@@ -0,0 +1,842 @@
"""Tests for children.py: the Feature 03 child-profile business layer."""
from __future__ import annotations
import asyncio
import logging
import re
from unittest.mock import AsyncMock, MagicMock
import pytest
from custom_components.nestquest.children import (
create_child,
edit_child,
list_children,
reorder_children,
set_child_active,
)
from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
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 _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database = NestQuestDatabase(_make_hass_mock())
await database.open(tmp_path / name)
try:
await apply_migrations(database)
return await body(database)
finally:
await database.close()
return _run(_main())
return _run_test
# ---------------------------------------------------------------------------
# create_child: required, trimmed display name
# ---------------------------------------------------------------------------
def test_create_child_returns_typed_record(tmp_path) -> None:
async def _body(database):
child = await create_child(
database,
" Declan ",
colour="#2E7D32",
avatar_ref="avatars/declan.png",
sort_order=2,
)
assert isinstance(child, ChildRecord)
# The name is trimmed before storage.
assert child.display_name == "Declan"
assert child.colour == "#2E7D32"
assert child.avatar_ref == "avatars/declan.png"
assert child.sort_order == 2
assert child.is_active is True
# The business layer stamps created_at in strict UTC shape.
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00", child.created_at
)
return child
_with_db(tmp_path, "create-typed.db")(_body)
def test_create_child_defaults(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Chloe")
assert child.display_name == "Chloe"
assert child.colour is None
assert child.avatar_ref is None
assert child.sort_order == 0
return child
_with_db(tmp_path, "create-defaults.db")(_body)
@pytest.mark.parametrize("bad", ["", " ", "\t\n", None])
def test_create_child_missing_name_rejected(tmp_path, bad) -> None:
async def _body(database):
with pytest.raises(ValueError, match="display_name is required"):
await create_child(database, bad)
assert await list_children(database) == []
return None
_with_db(tmp_path, "create-empty-name.db")(_body)
@pytest.mark.parametrize("bad", [42, ["Ada"], 3.5])
def test_create_child_non_string_name_rejected(tmp_path, bad) -> None:
async def _body(database):
with pytest.raises(ValueError, match="must be a string"):
await create_child(database, bad)
return None
_with_db(tmp_path, "create-bad-name-type.db")(_body)
def test_create_child_optional_text_fields_validated(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError, match="colour must be a string"):
await create_child(database, "Ada", colour=7)
with pytest.raises(ValueError, match="avatar_ref must be a string"):
await create_child(database, "Ada", avatar_ref=1.5)
return None
_with_db(tmp_path, "create-bad-optional.db")(_body)
@pytest.mark.parametrize("bad", ["2", 1.5, True, None])
def test_create_child_sort_order_must_be_int(tmp_path, bad) -> None:
async def _body(database):
with pytest.raises(ValueError, match="sort_order must be an integer"):
await create_child(database, "Ada", sort_order=bad)
return None
_with_db(tmp_path, "create-bad-order.db")(_body)
# ---------------------------------------------------------------------------
# create_child: duplicate display name allowed but warned
# ---------------------------------------------------------------------------
def test_create_child_duplicate_name_allowed_and_warned(
tmp_path, caplog
) -> None:
async def _body(database):
first = await create_child(database, "Ada")
with caplog.at_level(
logging.WARNING, logger="custom_components.nestquest.children"
):
second = await create_child(database, " ADA ")
# Whitespace/case-normalised duplicate: allowed, both rows exist.
assert first.display_name == "Ada"
assert second.display_name == "ADA"
names = [c.display_name for c in await list_children(database)]
assert names == ["Ada", "ADA"]
warnings = [
r for r in caplog.records if "duplicates existing child" in r.getMessage()
]
assert len(warnings) == 1, (
"exactly one duplicate warning for the second create"
)
assert "Ada" in warnings[0].getMessage()
return second
_with_db(tmp_path, "create-duplicate.db")(_body)
def test_create_child_distinct_name_logs_no_warning(
tmp_path, caplog
) -> None:
async def _body(database):
await create_child(database, "Ada")
caplog.clear()
await create_child(database, "Jordyn")
warnings = [
r for r in caplog.records if "duplicates existing child" in r.getMessage()
]
assert warnings == []
return None
_with_db(tmp_path, "create-no-duplicate.db")(_body)
# ---------------------------------------------------------------------------
# edit_child: returns the updated record
# ---------------------------------------------------------------------------
def test_edit_child_updates_fields_and_returns_record(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Ada", colour="#111111")
edited = await edit_child(
database,
child.id,
display_name=" Ada L. ",
colour="#222222",
avatar_ref="avatars/ada.png",
sort_order=3,
)
assert isinstance(edited, ChildRecord)
assert edited.id == child.id
assert edited.display_name == "Ada L."
assert edited.colour == "#222222"
assert edited.avatar_ref == "avatars/ada.png"
assert edited.sort_order == 3
# The stored row reflects every edit.
stored = await list_children(database)
assert len(stored) == 1
assert stored[0] == edited
return edited
_with_db(tmp_path, "edit-round-trip.db")(_body)
def test_edit_child_omitted_fields_unchanged(tmp_path) -> None:
async def _body(database):
child = await create_child(
database, "Ada", colour="#111111", sort_order=5
)
edited = await edit_child(database, child.id, sort_order=9)
# Only sort_order moved; colour and name untouched.
assert edited.sort_order == 9
assert edited.colour == "#111111"
assert edited.display_name == "Ada"
return edited
_with_db(tmp_path, "edit-partial.db")(_body)
def test_edit_child_no_fields_returns_current_record(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Ada", colour="#111111")
edited = await edit_child(database, child.id)
assert edited == child
return edited
_with_db(tmp_path, "edit-noop.db")(_body)
def test_edit_child_missing_raises(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError, match="does not exist"):
await edit_child(database, 999, display_name="X")
return None
_with_db(tmp_path, "edit-missing.db")(_body)
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
def test_edit_child_non_int_id_rejected(tmp_path, bad) -> None:
"""SQLite binds bools as integers: True must not address child 1,
and floats must not round onto a real profile."""
async def _body(database):
child = await create_child(database, "Ada")
with pytest.raises(ValueError, match="child_id must be an integer"):
await edit_child(database, bad, display_name="X")
# The refused edit left the profile untouched.
assert (await list_children(database))[0].display_name == "Ada"
return None
_with_db(tmp_path, "edit-bad-id.db")(_body)
@pytest.mark.parametrize("bad", [True, False, 1.0, "1", None])
def test_set_child_active_non_int_id_rejected(tmp_path, bad) -> None:
async def _body(database):
child = await create_child(database, "Ada")
with pytest.raises(ValueError, match="child_id must be an integer"):
await set_child_active(database, bad, False)
assert (await list_children(database))[0].is_active is True
return None
_with_db(tmp_path, "set-active-bad-id.db")(_body)
def test_edit_child_name_validation(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Ada")
for bad in ("", " ", None, 42):
with pytest.raises(ValueError, match="display_name"):
await edit_child(database, child.id, display_name=bad)
# The failed edits left the record untouched.
assert (await list_children(database))[0].display_name == "Ada"
return None
_with_db(tmp_path, "edit-name-validation.db")(_body)
def test_edit_child_sort_order_must_be_int(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Ada")
with pytest.raises(ValueError, match="sort_order must be an integer"):
await edit_child(database, child.id, sort_order="3")
return None
_with_db(tmp_path, "edit-bad-order.db")(_body)
def test_edit_child_explicit_none_clear_rejected(tmp_path) -> None:
"""Clearing is refused loudly, not silently ignored: the DAO cannot
express NULL writes, so an explicit None must never masquerade as a
no-op."""
async def _body(database):
child = await create_child(database, "Ada", colour="#111111")
with pytest.raises(ValueError, match="clearing colour"):
await edit_child(database, child.id, colour=None)
with pytest.raises(ValueError, match="clearing avatar_ref"):
await edit_child(database, child.id, avatar_ref=None)
# The refused clears left the colour intact.
assert (await list_children(database))[0].colour == "#111111"
return None
_with_db(tmp_path, "edit-clear-unsupported.db")(_body)
def test_edit_child_rename_to_duplicate_warns_but_different_name_not(
tmp_path, caplog
) -> None:
async def _body(database):
ada = await create_child(database, "Ada")
bo = await create_child(database, "Bo")
caplog.clear()
# Renaming Bo onto Ada's name warns (different child's name).
edited = await edit_child(database, bo.id, display_name="ada")
assert edited.display_name == "ada"
warnings = [
r for r in caplog.records if "duplicates existing child" in r.getMessage()
]
assert len(warnings) == 1
assert "Ada" in warnings[0].getMessage()
return edited
_with_db(tmp_path, "edit-rename-duplicate.db")(_body)
def test_edit_child_rename_to_own_name_casing_alone_warns_not(
tmp_path, caplog
) -> None:
"""Renaming a child to their own name (casing aside) is not a
duplicate when NO OTHER child carries the normalised name."""
async def _body(database):
ada = await create_child(database, "Ada")
caplog.clear()
edited = await edit_child(database, ada.id, display_name="ADA")
assert edited.display_name == "ADA"
assert not any(
"duplicates existing child" in r.getMessage()
for r in caplog.records
)
return edited
_with_db(tmp_path, "edit-rename-self.db")(_body)
def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
"""Racing creates of the same normalised name cannot slip through
unlogged: the scan and INSERT are serialized on the connection
lock, so the second create sees the first's committed row and
warns. Deterministic gate on task A's INSERT while task B queues
behind the lock. Without the lock, B's scan would run before A's
INSERT commits and no warning would fire."""
async def _main():
armed = {"active": False, "gated": False}
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
logger = logging.getLogger(
"custom_components.nestquest.children"
)
seen: list[logging.LogRecord] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
seen.append(record)
handler = _Capture()
logger.addHandler(handler)
try:
armed["active"] = True
task_a = asyncio.ensure_future(
create_child(database, "Ada")
)
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
create_child(database, " ada ")
)
await asyncio.sleep(0)
gate_open.set()
first, second = await asyncio.gather(task_a, task_b)
finally:
logger.removeHandler(handler)
# Both creates land; the duplicate is allowed.
assert first.display_name == "Ada"
assert second.display_name == "ada"
names = [c.display_name for c in await list_children(database)]
assert names == ["Ada", "ada"]
# Exactly one warning: B's scan saw A's committed row.
warnings = [
r for r in seen if "duplicates existing child" in r.getMessage()
]
assert len(warnings) == 1, (
"the queued duplicate create must warn exactly once"
)
assert "Ada" in warnings[0].getMessage()
finally:
await database.close()
_run(_main())
def test_edit_child_case_only_edit_colliding_with_other_child_warns(
tmp_path, caplog
) -> None:
"""A case-only edit must not hide a real duplicate: with two Ada
profiles already present, editing either one's name to another
casing still warns (the scan excludes only the edited child)."""
async def _body(database):
first = await create_child(database, "Ada")
await create_child(database, "ada")
caplog.clear()
edited = await edit_child(database, first.id, display_name="ADA")
assert edited.display_name == "ADA"
warnings = [
r for r in caplog.records if "duplicates existing child" in r.getMessage()
]
assert len(warnings) == 1
# Editing the name to a value that STAYS duplicated (no change,
# other child still carries it) warns too: the scan is on the
# resulting state, not on whether the word moved.
caplog.clear()
await edit_child(database, first.id, display_name=" ada ")
warnings = [
r for r in caplog.records if "duplicates existing child" in r.getMessage()
]
assert len(warnings) == 1
return None
_with_db(tmp_path, "edit-case-only-duplicate.db")(_body)
# ---------------------------------------------------------------------------
# set_child_active / list_children
# ---------------------------------------------------------------------------
def test_set_child_active_round_trip(tmp_path) -> None:
async def _body(database):
child = await create_child(database, "Ada")
deactivated = await set_child_active(database, child.id, False)
assert deactivated.is_active is False
assert [c.id for c in await list_children(database)] == [child.id]
assert await list_children(database, active_only=True) == []
reactivated = await set_child_active(database, child.id, True)
assert reactivated.is_active is True
assert len(await list_children(database, active_only=True)) == 1
return reactivated
_with_db(tmp_path, "set-active.db")(_body)
def test_set_child_active_missing_raises(tmp_path) -> None:
async def _body(database):
with pytest.raises(ValueError, match="does not exist"):
await set_child_active(database, 999, False)
return None
_with_db(tmp_path, "set-active-missing.db")(_body)
# ---------------------------------------------------------------------------
# reorder_children: complete permutation or nothing changes
# ---------------------------------------------------------------------------
def test_reorder_children_persists_display_order(tmp_path) -> None:
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
second = await create_child(database, "Bo", sort_order=1)
third = await create_child(database, "Cleo", sort_order=2)
await reorder_children(database, [third.id, first.id, second.id])
# The active list returns children in the new sort order.
listed = await list_children(database, active_only=True)
assert [c.id for c in listed] == [third.id, first.id, second.id]
assert [c.sort_order for c in listed] == [0, 1, 2]
return listed
_with_db(tmp_path, "reorder-round-trip.db")(_body)
def test_reorder_children_partial_list_rejected_order_unchanged(
tmp_path,
) -> None:
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
second = await create_child(database, "Bo", sort_order=1)
third = await create_child(database, "Cleo", sort_order=2)
with pytest.raises(ValueError, match="missing child ids"):
await reorder_children(database, [third.id, first.id])
listed = await list_children(database)
# Nothing changed: rejection happens before any write.
assert [c.id for c in listed] == [first.id, second.id, third.id]
assert [c.sort_order for c in listed] == [0, 1, 2]
return listed
_with_db(tmp_path, "reorder-partial.db")(_body)
def test_reorder_children_unknown_id_rejected_order_unchanged(
tmp_path,
) -> None:
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
second = await create_child(database, "Bo", sort_order=1)
with pytest.raises(ValueError, match="unknown child ids"):
await reorder_children(database, [second.id, first.id, 999])
listed = await list_children(database)
assert [c.id for c in listed] == [first.id, second.id]
assert [c.sort_order for c in listed] == [0, 1]
return listed
_with_db(tmp_path, "reorder-unknown.db")(_body)
def test_reorder_children_empty_list_rejected_when_children_exist(
tmp_path,
) -> None:
"""An empty list is the maximally partial reorder: rejected while
any child exists (against an empty table it is a complete
permutation and stays a harmless no-op)."""
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
with pytest.raises(ValueError, match="missing child ids"):
await reorder_children(database, [])
assert [c.sort_order for c in await list_children(database)] == [0]
return None
_with_db(tmp_path, "reorder-empty.db")(_body)
def test_reorder_children_duplicate_and_non_int_rejected(tmp_path) -> None:
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
second = await create_child(database, "Bo", sort_order=1)
with pytest.raises(ValueError, match="duplicate child ids"):
await reorder_children(database, [first.id, first.id, second.id])
with pytest.raises(ValueError, match="child_id must be an integer"):
await reorder_children(database, [first.id, second.id, True])
with pytest.raises(ValueError, match="child_id must be an integer"):
await reorder_children(database, [first.id, second.id, "3"])
listed = await list_children(database)
assert [c.sort_order for c in listed] == [0, 1]
return listed
_with_db(tmp_path, "reorder-bad-inputs.db")(_body)
def test_reorder_children_inactive_children_included(tmp_path) -> None:
"""Inactive children keep their display position: the permutation
covers the whole table, not just the active list."""
async def _body(database):
first = await create_child(database, "Ada", sort_order=0)
second = await create_child(database, "Bo", sort_order=1)
await set_child_active(database, second.id, False)
# Both ids are required even though one is inactive.
await reorder_children(database, [second.id, first.id])
listed = await list_children(database)
assert [c.id for c in listed] == [second.id, first.id]
assert [c.sort_order for c in listed] == [0, 1]
# The active list skips the inactive child but keeps position.
assert [c.id for c in await list_children(database, active_only=True)] == [
first.id
]
return listed
_with_db(tmp_path, "reorder-inactive.db")(_body)
@pytest.mark.parametrize("bad", ["1", "0", 1, 0, 1.0, None, "true"])
def test_set_child_active_rejects_non_bool(tmp_path, bad) -> None:
"""The DAO's int() would silently coerce "1"/0/1.0 into a state the
caller never asked for; the business layer refuses non-bools."""
async def _body(database):
child = await create_child(database, "Ada")
with pytest.raises(ValueError, match="is_active must be a real bool"):
await set_child_active(database, child.id, bad)
# The refused transition left the child untouched.
assert (await list_children(database))[0].is_active is True
return None
_with_db(tmp_path, "set-active-non-bool.db")(_body)
# ---------------------------------------------------------------------------
# Concurrency: edit/set_active are atomic under the connection lock
# ---------------------------------------------------------------------------
def _gated_hass(armed: dict, gate_open: asyncio.Event, a_started: asyncio.Event):
"""A hass mock whose executor gates the FIRST `_execute` job after
arming — the mutate statement of whichever business call starts
first — proving the caller holds the connection lock at that
moment and the second caller must queue behind it."""
from unittest.mock import MagicMock
hass = MagicMock()
async def _gated_executor(fn, *args):
if armed["active"]:
if getattr(fn, "__name__", "") == "_execute" and not armed["gated"]:
armed["gated"] = True
a_started.set()
await gate_open.wait()
return fn(*args)
hass.async_add_executor_job = _gated_executor
return hass
def test_edit_child_concurrent_opposite_edits_return_own_values(
tmp_path,
) -> None:
"""Two racing edits cannot cross-contaminate each other's readback.
Deterministic gate: task A is paused at its UPDATE (it provably
holds the connection lock), task B is then created and must queue;
A's readback runs before B's edit, so A returns A's value and B
returns B's. Without the lock, A's readback would see B's write.
"""
async def _main():
armed = {"active": False, "gated": False}
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "edit-race.db")
try:
await apply_migrations(database)
child = await create_child(
database, "Ada", colour="#111111", sort_order=0
)
armed["active"] = True
task_a = asyncio.ensure_future(
edit_child(database, child.id, colour="#AAAAAA", sort_order=1)
)
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
edit_child(
database, child.id, colour="#BBBBBB", sort_order=2
)
)
await asyncio.sleep(0)
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
# Each caller gets back exactly what it wrote.
assert record_a.colour == "#AAAAAA"
assert record_a.sort_order == 1
assert record_b.colour == "#BBBBBB"
assert record_b.sort_order == 2
# B landed last: the stored row is B's (read via the DAO —
# the children leak guard forbids raw probes here).
stored = await ChildrenDao(database).get(child.id)
assert stored is not None
assert (stored.colour, stored.sort_order) == ("#BBBBBB", 2)
finally:
await database.close()
_run(_main())
def test_set_child_active_concurrent_opposite_transitions_return_own(
tmp_path,
) -> None:
"""Racing activate/deactivate calls each report their own request.
Same deterministic gate on task A's UPDATE; task B queues behind
the connection lock and lands the opposite transition after A.
"""
async def _main():
armed = {"active": False, "gated": False}
gate_open = asyncio.Event()
a_started = asyncio.Event()
hass = _gated_hass(armed, gate_open, a_started)
database = NestQuestDatabase(hass)
await database.open(tmp_path / "active-race.db")
try:
await apply_migrations(database)
child = await create_child(database, "Ada")
armed["active"] = True
task_a = asyncio.ensure_future(
set_child_active(database, child.id, False)
)
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
set_child_active(database, child.id, True)
)
await asyncio.sleep(0)
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.is_active is False
assert record_b.is_active is True
stored = await ChildrenDao(database).get(child.id)
assert stored is not None
assert stored.is_active is True
finally:
await database.close()
_run(_main())
def test_list_children_orders_by_sort_order_then_id(tmp_path) -> None:
async def _body(database):
second = await create_child(database, "Bo", sort_order=1)
first = await create_child(database, "Ada", sort_order=0)
third = await create_child(database, "Cleo", sort_order=1)
listed = await list_children(database)
# sort_order 0 first, then the two sort_order-1 rows by id.
assert [c.id for c in listed] == [first.id, second.id, third.id]
return listed
_with_db(tmp_path, "list-order.db")(_body)
# ---------------------------------------------------------------------------
# Deactivation keeps every referencing row intact (Feature 03)
# ---------------------------------------------------------------------------
def test_deactivating_child_preserves_definitions_instances_events(
tmp_path,
) -> None:
async def _body(database):
from datetime import date, timedelta
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.dao_rules import (
QuestDefinitionsDao,
ScheduleRulesDao,
)
children = ChildrenDao(database)
rules = ScheduleRulesDao(database)
definitions = QuestDefinitionsDao(database)
instances = QuestInstancesDao(database)
events = CompletionEventsDao(database)
child = await create_child(database, "Ada")
rule = await rules.create("daily", "2026-01-01")
definition = await definitions.create(
"Brush teeth",
rule.id,
"2026-09-15T12:00:00+00:00",
assignee_child_ids=[child.id],
)
due = (date.today() + timedelta(days=1)).isoformat()
stamp = f"{due}T08:00:00+00:00"
instance = await instances.upsert(
definition.id, child.id, due, stamp, window="morning"
)
event = await events.append(
instance.id,
child.id,
"completed",
"panel",
f"{due}T09:00:00+00:00",
True,
actor_child_id=child.id,
)
# Deactivate: the referencing rows must not move.
deactivated = await set_child_active(database, child.id, False)
assert deactivated.is_active is False
assert await children.get(child.id) == deactivated
assert (await definitions.get(definition.id)) == definition
assert (await instances.get_by_id(instance.id)) == instance
assert await events.list_by_instance(instance.id) == [event]
# Excluded from the active list, still present in the full list.
assert await list_children(database, active_only=True) == []
assert [c.id for c in await list_children(database)] == [child.id]
# Reactivate: fully restored.
reactivated = await set_child_active(database, child.id, True)
assert reactivated.is_active is True
assert [c.id for c in await list_children(database, active_only=True)] == [
child.id
]
assert (await instances.get_by_id(instance.id)) == instance
return reactivated
_with_db(tmp_path, "deactivate-data-intact.db")(_body)
def test_no_code_path_deletes_a_child_row() -> None:
"""Structural guardrail: children are deactivated, never deleted.
The DAO exposes no delete method at all, and no module in the
package (nor any test outside the sanctioned DDL/migration layers,
which create the tables) carries a statement that removes child
rows — the leak guard in test_dao_children enforces the file
allowlist, this asserts the no-delete property itself across every
file that may legitimately name the table.
"""
import re
from pathlib import Path
package = Path(
__import__(
"custom_components.nestquest", fromlist=["__file__"]
).__file__
).parent
delete_pattern = re.compile(
r"DELETE\s+(FROM\s+)?((main|temp)\s*\.\s*)?[`'\"]*(\[)?children\b",
re.IGNORECASE,
)
for py in sorted(package.rglob("*.py")):
assert delete_pattern.search(py.read_text()) is None, (
f"a code path in {py.name} deletes child rows"
)
import inspect
from custom_components.nestquest.dao_children import ChildrenDao
methods = {
name
for name, _ in inspect.getmembers(ChildrenDao, inspect.isfunction)
}
assert not any(
name.startswith("delete") or name == "remove" for name in methods
), f"ChildrenDao exposed a delete-shaped method: {methods}"
+22 -1
View File
@@ -154,4 +154,25 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
)
assert node.value != "nestquest.db", (
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
)
)
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
"""The HA user completing the flow lands in entry data as the
initial admin copy: entry.context is not persisted by real HA, so
the durable record must carry the owner."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
flow = make_flow(hass)
flow.context = {"user_id": "ha-owner"}
result = await flow.async_step_user({})
assert result["type"] == "create_entry"
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
async def test_flow_without_context_user_keeps_data_untouched(
hass, make_flow
) -> None:
flow = make_flow(hass)
result = await flow.async_step_user({"horizon_days": 7})
assert result["type"] == "create_entry"
assert result["data"] == {"horizon_days": 7}
+14 -4
View File
@@ -227,24 +227,34 @@ def test_reorder_is_transactional_and_idempotent(tmp_path) -> None:
_run(database.close())
def test_reorder_empty_list_is_noop(tmp_path) -> None:
def test_reorder_empty_list_is_rejected(tmp_path) -> None:
"""An empty list is the maximally partial reorder: rejected while
any child exists (Feature 03 reorder contract)."""
import pytest as pytest_module
database = _open_db(tmp_path / "reorder-empty.db")
try:
dao, _ = _daos(database)
_make_child(dao, "Ada")
_run(dao.reorder([]))
with pytest_module.raises(ValueError, match="missing child ids"):
_run(dao.reorder([]))
children = _run(dao.list_all())
assert children[0].sort_order == 0
finally:
_run(database.close())
def test_reorder_unknown_id_leaves_it_untouched(tmp_path) -> None:
def test_reorder_unknown_id_is_rejected(tmp_path) -> None:
"""An unknown id is rejected before any write; the order is
untouched (Feature 03 reorder contract)."""
import pytest as pytest_module
database = _open_db(tmp_path / "reorder-unknown.db")
try:
dao, _ = _daos(database)
a = _make_child(dao, "Ada")
_run(dao.reorder([a.id, 999]))
with pytest_module.raises(ValueError, match="unknown child ids"):
_run(dao.reorder([a.id, 999]))
children = _run(dao.list_all())
assert children[0].sort_order == 0
finally:
+36 -10
View File
@@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
definition.id, child.id, D1, _now_stamp(), window="morning")
event = await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id,
actor_user_id=None,
)
assert event.actor_source == "panel"
@@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
with pytest.raises(ValueError, match="must not carry"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id,
actor_user_id="user-1",
)
with pytest.raises(ValueError, match="requires actor_child_id"):
await events.append(
instance.id, child.id, "completed", "panel",
_now_stamp(), True,
)
with pytest.raises(ValueError, match="must not carry actor_child_id"):
await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
actor_child_id=child.id,
)
with pytest.raises(ValueError, match="requires was_on_time"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), None
instance.id, child.id, "completed", "panel", _now_stamp(), None,
actor_child_id=child.id
)
# was_on_time must be a real bool: int-convertible junk that
# would silently coerce (0.5 -> False) is rejected outright.
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
instance.id, child.id, "completed", "panel",
_now_stamp(), 0.5, actor_child_id=child.id,
)
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
@@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
with pytest.raises(ValueError, match="UTC"):
await events.append(
instance.id, child.id, "completed", "panel", bad,
True,
True, actor_child_id=child.id,
)
return None
@@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
history = await events.list_by_instance(instance.id)
assert [e.event_type for e in history] == [
@@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
@@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
late = await instances.upsert(
definition.id, child.id, D20, _now_stamp(), window="morning")
await events.append(
early.id, child.id, "completed", "panel", _now_stamp(), True
early.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
inside.id, child.id, "completed", "panel", _now_stamp(), False
inside.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
await events.append(
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
late.id, child.id, "completed", "panel", _now_stamp(), True
late.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
found = await events.list_by_child_and_date_range(
child.id, D2, D2
@@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
# queried on today's date finds it too.
event = await events.append(
late_due.id, child.id, "completed", "panel",
_now_stamp(), False,
_now_stamp(), False, actor_child_id=child.id,
)
by_due = await events.list_by_child_and_date_range(
child.id, D20, D20
@@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
"tests/test_migrations.py", # seeds frozen legacy shapes to
# exercise the rebuild migrations
"tests/test_dao_instances.py", # this guard file; scanned by
# its own guard's regex spans below instead
}
+4 -2
View File
@@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
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
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
actor_child_id=w.child.id,
)
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
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
actor_child_id=w.child.id,
)
# read: full ordered history + latest
history = await w.events.list_by_instance(w.instance.id)
+170 -1
View File
@@ -619,4 +619,173 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
)
assert sql_pattern.search(remaining) is None, (
"tests must go through the DAO, not raw SQL, for these tables"
)
)
# ---------------------------------------------------------------------------
# Presence overrides: overlap rejection naming the conflict
# ---------------------------------------------------------------------------
def test_override_create_round_trip_single_and_range(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
single = await overrides.create(
child.id, "2026-07-20", "2026-07-20", False
)
assert (single.start_date, single.end_date) == ("2026-07-20",) * 2
span = await overrides.create(
child.id, "2026-08-01", "2026-08-05", True, note="traded week"
)
assert span.is_present is True
assert span.note == "traded week"
# Distinct, non-overlapping ranges coexist.
listed = await overrides.list_by_child_and_range(
child.id, "2026-01-01", "2026-12-31"
)
assert [row.id for row in listed] == [single.id, span.id]
return listed
_with_db(tmp_path, "override-round-trip.db")(_body)
def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
existing = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False, note="holiday"
)
for start, end in (
("2026-07-24", "2026-07-26"), # touches the existing end
("2026-07-18", "2026-07-20"), # touches the existing start
("2026-07-21", "2026-07-22"), # fully inside
("2026-07-01", "2026-07-30"), # fully covering
):
with pytest.raises(ValueError, match="overlaps existing"):
await overrides.create(
child.id, start, end, True, note="swap"
)
# The error NAMES the conflicting override (id + its range).
with pytest.raises(
ValueError,
match=(
rf"override {existing.id} "
rf"\[2026-07-20, 2026-07-24\].*is_present=False"
),
):
await overrides.create(child.id, "2026-07-22", "2026-07-22", True)
# Nothing was written by any rejected attempt.
listed = await overrides.list_by_child_and_range(
child.id, "2026-01-01", "2026-12-31"
)
assert [row.id for row in listed] == [existing.id]
return existing
_with_db(tmp_path, "override-overlap.db")(_body)
def test_override_overlap_is_per_child(tmp_path) -> None:
"""Different children's overrides may cover the same dates: the
conflict rule is per child."""
async def _body(database, schedules, overrides, children, child):
other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-07-20", "2026-07-24", False)
twin = await overrides.create(
other.id, "2026-07-20", "2026-07-24", True
)
assert twin.child_id == other.id
listed = await overrides.list_by_child_and_range(
other.id, "2026-01-01", "2026-12-31"
)
assert [row.id for row in listed] == [twin.id]
return twin
_with_db(tmp_path, "override-per-child.db")(_body)
def test_override_remove_then_recreate_overlapping(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False
)
assert await overrides.delete(first.id) is True
assert await overrides.delete(first.id) is False
replacement = await overrides.create(
child.id, "2026-07-20", "2026-07-24", True, note="swap back"
)
assert replacement.is_present is True
return replacement
_with_db(tmp_path, "override-recreate.db")(_body)
def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
"""Two racing creates for the same child and dates: exactly one
lands, the other is rejected with the overlap error. DETERMINISTIC
gate: task A is paused at its INSERT — inside its open transaction,
after its overlap probe — so task B is created while A provably
holds the connection lock and must queue; B then re-runs the probe
against A's committed row and is rejected."""
import asyncio as asyncio_module
from unittest.mock import MagicMock
async def _main():
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_presence import (
PresenceOverridesDao,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
armed = {"active": False, "gated": False}
gate_open = asyncio_module.Event()
a_started = asyncio_module.Event()
hass = MagicMock()
async def _gated_executor(fn, *args):
if armed["active"]:
# Gate the FIRST _execute after arming: task A's INSERT,
# which runs inside its open transaction (BEGIN already
# executed) after the overlap probe.
if getattr(fn, "__name__", "") == "_execute" 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 / "override-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
overrides = PresenceOverridesDao(database)
child = await children.create("Ada", NOW)
armed["active"] = True
task_a = asyncio_module.ensure_future(
overrides.create(child.id, "2026-07-20", "2026-07-24", True)
)
await a_started.wait()
armed["active"] = False
task_b = asyncio_module.ensure_future(
overrides.create(child.id, "2026-07-22", "2026-07-26", False)
)
await asyncio_module.sleep(0)
gate_open.set()
record_a, error_b = await asyncio_module.gather(
task_a, task_b, return_exceptions=True
)
assert isinstance(record_a, object) and not isinstance(
record_a, BaseException
), f"A must land: {record_a!r}"
assert isinstance(error_b, ValueError), (
f"B must be rejected with the overlap error: {error_b!r}"
)
assert "overlaps existing" in str(error_b)
listed = await overrides.list_by_child_and_range(
child.id, "2026-01-01", "2026-12-31"
)
assert [row.id for row in listed] == [record_a.id]
finally:
await database.close()
asyncio.new_event_loop().run_until_complete(_main())
+251
View File
@@ -9,13 +9,21 @@ import pytest
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_rules import (
ScheduleRuleRecord,
ScheduleRuleStorage,
ScheduleRulesDao,
QuestDefinitionRecord,
QuestDefinitionWindowRecord,
QuestDefinitionsDao,
schedule_rule_from_storage,
schedule_rule_to_storage,
)
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
)
def _run(coro):
@@ -423,6 +431,249 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
assert create_won is False
# ---------------------------------------------------------------------------
# ScheduleRule <-> storage mapping
# ---------------------------------------------------------------------------
_RULE_STORAGE_CASES = [
(
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
ScheduleRuleStorage(
"daily", 1, None, None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.DAILY,
interval=3,
start_date="2026-09-01",
end_date="2026-12-31",
),
ScheduleRuleStorage(
"daily", 3, None, None, None, None, "2026-09-01", "2026-12-31"
),
),
(
ScheduleRule(
rule_type=RuleType.WEEKLY,
weekday_set={2, 0, 4},
start_date="2026-09-01",
),
ScheduleRuleStorage(
"weekly", 1, "0,2,4", None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS,
weekday_set={1, 3, 5},
interval=2,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"custom", 2, "1,3,5", None, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=15,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 1, None, 15, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_DAY,
day_of_month=31,
interval=3,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 3, None, 31, None, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=2,
nth_weekday_weekday=1,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 1, "1", None, 2, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY,
nth_weekday=-1,
nth_weekday_weekday=4,
interval=2,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"monthly", 2, "4", None, -1, None, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"
),
ScheduleRuleStorage(
"yearly", 1, None, None, None, 6, "2026-09-01", None
),
),
(
ScheduleRule(
rule_type=RuleType.YEARLY,
month=2,
day_of_month=29,
start_date="2026-09-01",
),
ScheduleRuleStorage(
"yearly", 1, None, 29, None, 2, "2026-09-01", None
),
),
]
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_to_storage_maps_each_type(rule, storage) -> None:
assert schedule_rule_to_storage(rule) == storage
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_from_storage_rebuilds_each_type(rule, storage) -> None:
assert schedule_rule_from_storage(storage) == rule
@pytest.mark.parametrize("rule, storage", _RULE_STORAGE_CASES)
def test_schedule_rule_round_trips_losslessly_both_directions(
rule, storage
) -> None:
assert schedule_rule_from_storage(schedule_rule_to_storage(rule)) == rule
assert schedule_rule_to_storage(schedule_rule_from_storage(storage)) == storage
@pytest.mark.parametrize(
"storage",
[
# monthly with neither day_of_month nor nth_weekday
ScheduleRuleStorage(
"monthly", 1, None, None, None, None, "2026-09-01", None
),
# monthly with both day_of_month and nth_weekday
ScheduleRuleStorage(
"monthly", 1, None, 15, 2, None, "2026-09-01", None
),
# MONTHLY_WEEKDAY whose weekday_set is None
ScheduleRuleStorage(
"monthly", 1, None, None, 2, None, "2026-09-01", None
),
# MONTHLY_WEEKDAY whose weekday_set is empty
ScheduleRuleStorage("monthly", 1, "", None, 2, None, "2026-09-01", None),
# MONTHLY_WEEKDAY whose weekday_set is multi-element
ScheduleRuleStorage(
"monthly", 1, "1,3", None, 2, None, "2026-09-01", None
),
# unknown storage rule_type
ScheduleRuleStorage(
"sometimes", 1, None, None, None, None, "2026-09-01", None
),
],
)
def test_schedule_rule_from_storage_rejects_ambiguous_or_invalid(
storage,
) -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(storage)
def test_schedule_rule_from_storage_rejects_malformed_weekday_csv() -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"weekly", 1, "0,2,x", None, None, None, "2026-09-01", None
)
)
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"weekly", 1, "7", None, None, None, "2026-09-01", None
)
)
@pytest.mark.parametrize(
"storage",
[
# DAILY carrying shape columns it must not hold.
ScheduleRuleStorage(
"daily", 1, "0", None, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, None, 2, None, "2026-09-01", None
),
ScheduleRuleStorage(
"daily", 1, None, None, None, 6, "2026-09-01", None
),
# WEEKLY carrying shape columns it must not hold.
ScheduleRuleStorage(
"weekly", 1, "0,2", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"weekly", 1, "0,2", None, None, 6, "2026-09-01", None
),
# CUSTOM_DAYS carrying shape columns it must not hold.
ScheduleRuleStorage(
"custom", 1, "0,2", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"custom", 1, "0,2", None, None, 6, "2026-09-01", None
),
# MONTHLY_DAY carrying weekday_set (folded-only column) or month.
ScheduleRuleStorage(
"monthly", 1, "1", 15, None, None, "2026-09-01", None
),
ScheduleRuleStorage(
"monthly", 1, None, 15, None, 6, "2026-09-01", None
),
# MONTHLY_WEEKDAY carrying a forbidden month.
ScheduleRuleStorage(
"monthly", 1, "1", None, 2, 6, "2026-09-01", None
),
# YEARLY carrying weekday_set or nth_weekday.
ScheduleRuleStorage(
"yearly", 1, "0", None, None, 6, "2026-09-01", None
),
ScheduleRuleStorage(
"yearly", 1, None, None, 2, 6, "2026-09-01", None
),
],
)
def test_schedule_rule_from_storage_rejects_forbidden_fields(storage) -> None:
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(storage)
def test_schedule_rule_from_storage_rejects_duplicate_weekday_token() -> None:
"""A MONTHLY_WEEKDAY with a duplicate CSV token ("1,1") must raise,
never silently canonicalize to a single weekday on re-storage."""
with pytest.raises(RuleValidationError):
schedule_rule_from_storage(
ScheduleRuleStorage(
"monthly", 1, "1,1", None, 2, None, "2026-09-01", None
)
)
# ---------------------------------------------------------------------------
# quest_definitions: create / get
# ---------------------------------------------------------------------------
+300 -2
View File
@@ -8,7 +8,12 @@ import pytest
from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
from custom_components.nestquest.const import (
CONF_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
SERVICE_REGENERATE,
DOMAIN as DOMAIN_CONST,
)
def _wire(entry, registry):
@@ -237,6 +242,130 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
assert database.connected is False, "database leaked despite remover error"
assert entry.runtime_data is None
assert DOMAIN not in hass.data
assert hass.time_change.size == 0, "time-change listener leaked"
async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None:
"""A raising update-listener remover must not skip the time-change cancel.
Both removers are independent: the update-listener remover raising must
still cancel the daily time-change callback, whose action would otherwise
fire after unload against a closed database.
"""
class _RemoveBoom(Exception):
pass
def _remover():
raise _RemoveBoom()
entry = make_config_entry(entry_id="update-boom-time-cancel")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
assert hass.time_change.size == 1
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert hass.time_change.size == 0, "time-change listener not cancelled"
assert entry.runtime_data is None
assert DOMAIN not in hass.data
async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None:
"""Setup registers a daily rollover listener and stores its cancel fn."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert registration["hour"] == 0
assert registration["minute"] == 0
assert callable(entry.runtime_data.remove_time_change_listener)
async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None:
"""The listener fires at the entry option's rollover time, not the default."""
entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry)
await async_setup_entry(hass, entry)
registration = hass.time_change.registrations[0]
assert registration["hour"] == 6
assert registration["minute"] == 30
async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None:
"""Unload removes the time-change listener alongside the update listener."""
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert hass.time_change.size == 1
assert await async_unload_entry(hass, entry) is True
assert hass.time_change.size == 0
async def test_rollover_time_change_reregisters_listener_at_new_time(
hass, make_entry
) -> None:
"""A rollover-time options change re-registers the listener at the new time."""
entry = _wire(
make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry
)
await async_setup_entry(hass, entry)
assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0)
# The options flow writes the new rollover time, then HA reloads the
# entry: unload tears down the old listener, setup re-registers it.
entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"}
await async_unload_entry(hass, entry)
assert hass.time_change.size == 0
assert await async_setup_entry(hass, entry) is True
assert hass.time_change.size == 1
registration = hass.time_change.registrations[0]
assert (registration["hour"], registration["minute"]) == (6, 30)
async def test_day_rollover_listener_materializes_ha_local_horizon(
hass, make_entry, monkeypatch
) -> None:
"""Firing the listener materializes today..today+horizon in HA local time."""
import datetime
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
import custom_components.nestquest as nestquest
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
fake = AsyncMock()
monkeypatch.setattr(nestquest, "_materialize_run", fake)
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
expected_start = today.isoformat()
expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
await hass.time_change.fire()
fake.assert_awaited_once_with(
database, expected_start, expected_end, today=today
)
def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None:
"""The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg).
Setup drives the local-time wrapper directly, so passing ``local=`` must
raise TypeError — otherwise a regression in the production call would be
silently masked by the fake.
"""
import inspect as _inspect
from homeassistant.helpers.event import async_track_time_change
params = _inspect.signature(async_track_time_change).parameters
assert list(params) == ["hass", "action", "hour", "minute", "second"]
with pytest.raises(TypeError):
async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True)
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
@@ -255,4 +384,173 @@ async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
"quest_definitions",
"schedule_rules",
]
await async_unload_entry(hass, entry)
await async_unload_entry(hass, entry)
async def _seed_daily_child_and_definition(database, today) -> int:
"""Create one always-present child + a daily definition starting today.
Returns the child's id. No presence schedule means the child is present
every day, so the daily rule yields exactly one instance per horizon day.
"""
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.quest_definitions import (
create_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
NOW = "2026-09-14T12:00:00+00:00"
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()),
[child.id],
["morning"],
)
return child.id
def _local_today(hass) -> "datetime.date":
import datetime
from zoneinfo import ZoneInfo
return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date()
async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
"""Setup materializes once so a restart backfills any missed days."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.store import async_get_db_path
# Seed the database BEFORE setup: a restart reopens an existing file that
# already holds definitions. The startup backfill must pick them up.
db_path = await async_get_db_path(hass)
pre = NestQuestDatabase(hass)
await pre.open(db_path)
await apply_migrations(pre)
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
await pre.close()
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_regenerate_service_registered_and_materializes(
hass, make_entry
) -> None:
"""``nestquest.regenerate`` is registered and calling it regenerates."""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# A definition created AFTER setup has no instances yet (create does not
# materialize); the service must generate them on demand.
database = entry.runtime_data.database
child_id = await _seed_daily_child_and_definition(database, _local_today(hass))
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None:
"""Unload removes the ``regenerate`` service so no stale entry leaks."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert await async_unload_entry(hass, entry) is True
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
async def test_regenerate_service_is_domain_scoped_across_entries(
hass, make_entry
) -> None:
"""The ``regenerate`` service is domain-global, not per-entry.
With two loaded entries the service must be registered and functional;
unloading one entry must keep it registered and functional for the
remaining entry; unloading the final entry must remove it.
"""
import datetime
from custom_components.nestquest.dao_instances import QuestInstancesDao
entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry)
entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# The domain-global handler resolves a live database from any entry.
database_b = entry_b.runtime_data.database
child_id = await _seed_daily_child_and_definition(
database_b, _local_today(hass)
)
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
today = _local_today(hass)
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
records = await QuestInstancesDao(database_b).list_by_date_range(
child_id, today.isoformat(), end
)
assert len(records) == DEFAULT_HORIZON_DAYS + 1
# Unloading one entry must not tear down the shared service.
assert await async_unload_entry(hass, entry_a) is True
assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
# It still works for the remaining entry.
await hass.services.call(DOMAIN, SERVICE_REGENERATE)
# Unloading the final entry removes the domain-global service.
assert await async_unload_entry(hass, entry_b) is True
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
async def test_setup_cleans_listeners_when_materialization_raises(
hass, make_entry, monkeypatch
) -> None:
"""A startup-materialization failure rolls back every registered listener.
The daily and update listeners are installed before the startup
materialization runs; when that walk raises, the database is closed but
the remover callables must also be invoked so no listener survives
firing against a closed database.
"""
import custom_components.nestquest as nestquest
async def _boom(*_args, **_kwargs):
raise RuntimeError("materialization failed")
monkeypatch.setattr(nestquest, "_materialize_run", _boom)
entry = _wire(make_entry(), hass.registry)
with pytest.raises(RuntimeError, match="materialization failed"):
await async_setup_entry(hass, entry)
assert hass.time_change.size == 0
assert hass.registry.size == 0
assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE)
assert not hasattr(entry, "runtime_data")
File diff suppressed because it is too large Load Diff
+58 -4
View File
@@ -149,16 +149,17 @@ def test_migration_list_shape() -> None:
(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, migration 4 adds the windows table, and migration 5 rebuilds
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key.
key, and migration 6 adds actor_child_id to completion_events.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert callable(MIGRATIONS[4])
assert len(MIGRATIONS) == 5
assert callable(MIGRATIONS[5])
assert len(MIGRATIONS) == 6
# ---------------------------------------------------------------------------
@@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [
""",
]
_V2_EVENTS_DDL = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_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_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file.
@@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"quest_definition_assignees ",
"quest_definition_windows ",
"quest_instances ",
"completion_events ",
)
for sql in SCHEMA_V1_STATEMENTS:
if any(name in sql for name in _v2_replaced):
continue
_run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL:
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
@@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
# One legacy panel event and one legacy admin event: the panel one
# must backfill actor_child_id to its own child; the admin one must
# stay NULL.
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, occurred_at, was_on_time) "
"VALUES (1, 2, 'completed', 'panel', "
"'2026-09-15T10:00:00+00:00', 1)"
)
)
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, occurred_at, "
"was_on_time) "
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
"'2026-09-15T11:00:00+00:00', 1)"
)
)
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
@@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
assert [
"definition_id", "child_id", "due_date", "window"
] in unique_columns
# The completion events survived the actor_child_id rebuild:
# the panel row backfilled to its own child, the admin row
# stayed NULL, and ids are preserved.
events = _run(
database.fetch_all(
"SELECT id, event_type, actor_source, actor_child_id "
"FROM completion_events ORDER BY id"
)
)
assert events == [
(1, "completed", "panel", 2),
(2, "uncompleted", "user", None),
]
columns = _run(
database.fetch_all("PRAGMA table_info(completion_events)")
)
assert "actor_child_id" in {c[1] for c in columns}
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition
+265 -4
View File
@@ -14,6 +14,7 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options
from custom_components.nestquest import config_flow
from custom_components.nestquest import options_flow
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
CONF_DAY_ROLLOVER_TIME,
CONF_HORIZON_DAYS,
CONF_PANEL_IDLE_TIMEOUT,
@@ -46,13 +47,48 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data:
return make_config_entry(entry_id=entry_id, options=options, data=data)
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
"""Create an options flow instance bound to a stubbed config entry."""
def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow:
"""Create an options flow instance bound to a stubbed config entry.
``users`` populates a stubbed hass.auth for the admin picker;
``database`` wires the runtime record so the flow can read and
write the allowlist like real HA's options flow does.
"""
from unittest.mock import AsyncMock
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
flow.hass = SimpleNamespace()
flow.hass.auth = SimpleNamespace(
async_get_users=AsyncMock(return_value=users or [])
)
if database is not None:
from custom_components.nestquest.const import DOMAIN
flow.hass.data = {
DOMAIN: {entry.entry_id: SimpleNamespace(database=database)}
}
return flow
def _user(user_id: str, name: str):
return SimpleNamespace(id=user_id, name=name)
def _open_allowlist_db(tmp_path, name):
"""A real migrated temp database for the flow to read and write."""
from unittest.mock import AsyncMock, MagicMock
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
database = NestQuestDatabase(hass)
_run(database.open(tmp_path / name))
_run(apply_migrations(database))
return database
def _schema_values(schema) -> dict:
"""Extract the per-field default values from a real voluptuous schema."""
return {
@@ -161,7 +197,12 @@ def test_options_flow_falls_back_to_entry_data() -> None:
def test_options_flow_schema_applies_stored_defaults() -> None:
"""A real voluptuous validate({}) fills every field from the stored defaults."""
"""A real voluptuous validate({}) fills every field from the stored defaults.
Without a runtime database (and no auth in the bare harness), the
admin picker's default is the empty selection and its choice set is
empty — the three legacy settings are unaffected.
"""
entry = _make_entry(
options={
CONF_HORIZON_DAYS: 21,
@@ -174,6 +215,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None:
CONF_HORIZON_DAYS: 21,
CONF_DAY_ROLLOVER_TIME: "05:45",
CONF_PANEL_IDLE_TIMEOUT: 120,
CONF_ADMIN_USER_IDS: [],
}
@@ -373,4 +415,223 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
)
assert node.value != "nestquest.db", (
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
)
)
# ---------------------------------------------------------------------------
# Admin allowlist picker (Feature 03)
# ---------------------------------------------------------------------------
def test_options_flow_picker_lists_existing_users(tmp_path) -> None:
"""The picker offers EXISTING HA users (id -> name), never free text."""
import voluptuous as vol
database = _open_allowlist_db(tmp_path, "picker-users.db")
try:
from custom_components.nestquest.admin_allowlist import set_admin_ids
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(flow.async_step_init(None))
schema = result["data_schema"]
marker = next(
m for m in schema.schema
if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS
)
# Default is the CURRENT database allowlist.
values = _schema_values(schema)
assert values[CONF_ADMIN_USER_IDS] == ["u1"]
# Both users are selectable; an unknown id is rejected by the
# schema itself (this is what real HA submits go through).
validated = schema(
{
CONF_HORIZON_DAYS: 7,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_PANEL_IDLE_TIMEOUT: 60,
CONF_ADMIN_USER_IDS: ["u1", "u2"],
}
)
assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"]
with pytest.raises(vol.Invalid):
schema(
{
CONF_HORIZON_DAYS: 7,
CONF_DAY_ROLLOVER_TIME: "03:30",
CONF_PANEL_IDLE_TIMEOUT: 60,
CONF_ADMIN_USER_IDS: ["impostor"],
}
)
finally:
_run(database.close())
def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None:
database = _open_allowlist_db(tmp_path, "picker-apply.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]})
)
assert result["type"] == "create_entry"
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
# The allowlist (and the entry's recovery copy) carry the pick.
assert await_gather(list_admin_ids(database)) == ["u2"]
finally:
_run(database.close())
def await_gather(coro):
return _run(coro)
def test_options_flow_empty_selection_refused(tmp_path) -> None:
"""Saving zero admins is refused: an empty allowlist fails closed."""
database = _open_allowlist_db(tmp_path, "picker-empty.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(
flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []})
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_unknown_selection_refused(tmp_path) -> None:
database = _open_allowlist_db(tmp_path, "picker-unknown.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None:
"""Raw callers omitting the key (pre-picker payloads) do not
touch the allowlist; the CURRENT allowlist is carried forward on
the options copy so no removed admin can be resurrected later."""
database = _open_allowlist_db(tmp_path, "picker-legacy.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua")],
database=database,
)
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"]
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_duplicate_selection_refused(tmp_path) -> None:
"""Duplicate ids surface as a field error, not an unhandled
ValueError from the business layer."""
database = _open_allowlist_db(tmp_path, "picker-dupe.db")
try:
from custom_components.nestquest.admin_allowlist import (
list_admin_ids,
set_admin_ids,
)
_run(set_admin_ids(database, ["u1"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(
flow.async_step_init(
{**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]}
)
)
assert result["type"] == "form"
assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"}
assert await_gather(list_admin_ids(database)) == ["u1"]
finally:
_run(database.close())
def test_options_flow_omitted_picker_carries_allowlist_forward(
tmp_path,
) -> None:
"""An omitted picker field must not drop the allowlist copy from
the returned options: HA replaces entry.options wholesale, and a
later database loss must not resurrect removed admins from the
stale config-flow copy in entry.data."""
database = _open_allowlist_db(tmp_path, "picker-carry.db")
try:
from custom_components.nestquest.admin_allowlist import (
set_admin_ids,
)
_run(set_admin_ids(database, ["u2"]))
entry = _make_entry()
flow = _make_flow(
entry,
users=[_user("u1", "Joshua"), _user("u2", "Sam")],
database=database,
)
result = _run(flow.async_step_init(dict(VALID_INPUT)))
assert result["type"] == "create_entry"
# The current allowlist rides along on the legacy three-key path.
assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"]
assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS]
finally:
_run(database.close())
+814
View File
@@ -0,0 +1,814 @@
"""Tests for presence.py: the Feature 05 custody model."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.presence import (
MAX_CYCLE_LENGTH_WEEKS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
cycle_week_index,
)
ANCHOR = datetime.date(2026, 1, 5) # a Monday
TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}}
# ---------------------------------------------------------------------------
# PresenceSchedule construction
# ---------------------------------------------------------------------------
def test_schedule_holds_validated_fields() -> None:
schedule = PresenceSchedule(
3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}}
)
assert schedule.child_id == 3
assert schedule.cycle_length_weeks == 2
assert schedule.anchor_date == ANCHOR
assert schedule.pattern == {0: frozenset({0, 2, 4}), 1: frozenset({1, 3})}
def test_schedule_accepts_date_objects() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
assert schedule.anchor_date is ANCHOR
def test_schedule_rejects_cycle_length_below_one() -> None:
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, 0, ANCHOR, {})
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, -1, ANCHOR, {})
def test_schedule_rejects_cycle_length_above_documented_cap() -> None:
"""The 4 cap matches the schema's CHECK — both documents agree."""
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, MAX_CYCLE_LENGTH_WEEKS + 1, ANCHOR, {})
assert MAX_CYCLE_LENGTH_WEEKS == 4
@pytest.mark.parametrize("bad", [True, "2", 1.0, None])
def test_schedule_rejects_non_int_cycle_length(bad) -> None:
with pytest.raises(ValueError, match="cycle_length_weeks must be an integer"):
PresenceSchedule(1, bad, ANCHOR, {0: {0}})
def test_schedule_rejects_pattern_missing_week_indices() -> None:
with pytest.raises(ValueError, match="missing \\[1\\]"):
PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}})
def test_schedule_rejects_pattern_with_extra_week_indices() -> None:
with pytest.raises(ValueError, match="week index must be in 0"):
PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}})
def test_schedule_rejects_pattern_with_non_int_week_keys() -> None:
with pytest.raises(ValueError, match="week index must be an integer"):
PresenceSchedule(1, 1, ANCHOR, {"0": {0}})
def test_schedule_rejects_pattern_with_bool_week_key() -> None:
"""True would silently be week 1; bools are never valid indices."""
with pytest.raises(ValueError, match="week index must be an integer"):
PresenceSchedule(1, 2, ANCHOR, {0: {0}, True: {1}})
@pytest.mark.parametrize("bad_weekday", [-1, 7, 100])
def test_schedule_rejects_weekday_outside_zero_to_six(bad_weekday) -> None:
with pytest.raises(ValueError, match="weekday must be in 0..6"):
PresenceSchedule(1, 1, ANCHOR, {0: {bad_weekday}})
@pytest.mark.parametrize("bad", [True, "1", 1.5, None])
def test_schedule_rejects_non_int_weekdays(bad) -> None:
with pytest.raises(ValueError, match="weekday must be an int"):
PresenceSchedule(1, 1, ANCHOR, {0: [bad]})
def test_schedule_rejects_string_and_none_week_sets() -> None:
"""A string is iterable — "02" would silently mean {0, 2}."""
with pytest.raises(ValueError, match="iterable of weekday ints"):
PresenceSchedule(1, 1, ANCHOR, {0: "02"})
with pytest.raises(ValueError, match="iterable of weekday ints"):
PresenceSchedule(1, 1, ANCHOR, {0: None})
def test_schedule_rejects_non_dict_pattern() -> None:
with pytest.raises(ValueError, match="must map week index"):
PresenceSchedule(1, 1, ANCHOR, "0,2")
@pytest.mark.parametrize("bad", [True, 1.0, "2026-01-05", None])
def test_schedule_rejects_non_int_child_id(bad) -> None:
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceSchedule(bad, 1, ANCHOR, {0: {0}})
@pytest.mark.parametrize(
"bad", ["2026-1-5", "2026/01/05", "not-a-date", 5, None, True]
)
def test_schedule_rejects_non_strict_anchor_dates(bad) -> None:
with pytest.raises(ValueError, match="anchor_date"):
PresenceSchedule(1, 1, bad, {0: {0}})
def test_schedule_empty_week_means_absent_week() -> None:
"""An explicitly empty week is valid: absent every day of it —
distinct from having no schedule (the engine's default)."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.pattern[1] == frozenset()
def test_schedule_empty_week_encodes_as_empty_segment() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert schedule.encode() == "0,2|"
def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None:
one = PresenceSchedule(1, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
two = PresenceSchedule(2, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}})
assert one.encode() == two.encode() == "0,2,4|1,3"
# ---------------------------------------------------------------------------
# Serialization round-trip
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"encoded",
[
"0,2,4|1,3",
"0|1,2,3,4,5,6",
"|",
"0,2|",
"|0,2",
"6",
"0,1,2,3,4,5,6|",
],
)
def test_schedule_decode_encode_round_trips_losslessly(encoded) -> None:
schedule = PresenceSchedule.decode(1, ANCHOR, encoded)
assert schedule.encode() == encoded
again = PresenceSchedule.decode(1, ANCHOR, schedule.encode())
assert again == schedule
def test_schedule_decode_sets_cycle_length_from_segments() -> None:
one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4")
assert one_week.cycle_length_weeks == 1
four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4")
assert four_week.cycle_length_weeks == 4
def test_schedule_decode_rejects_malformed_segments() -> None:
for bad in ("0,12|1", "0,,2|1", "x|1", "0|-1|2", "8", "0,|1"):
with pytest.raises(ValueError, match="invalid weekday"):
PresenceSchedule.decode(1, ANCHOR, bad)
def test_schedule_decode_rejects_non_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
PresenceSchedule.decode(1, ANCHOR, None)
def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None:
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule.decode(1, ANCHOR, "0|1|2|3|4")
# ---------------------------------------------------------------------------
# PresenceOverride
# ---------------------------------------------------------------------------
def test_override_single_date_and_range_cover() -> None:
single = PresenceOverride(1, "2026-07-20", "2026-07-20", False)
assert single.covers(datetime.date(2026, 7, 20))
assert not single.covers(datetime.date(2026, 7, 21))
span = PresenceOverride(1, "2026-07-20", "2026-07-24", True, note="trade")
assert span.covers(datetime.date(2026, 7, 22))
assert not span.covers(datetime.date(2026, 7, 19))
assert not span.covers(datetime.date(2026, 7, 25))
assert span.note == "trade"
def test_override_end_before_start_rejected() -> None:
with pytest.raises(ValueError, match="end_date must be on or after"):
PresenceOverride(1, "2026-07-24", "2026-07-20", True)
@pytest.mark.parametrize("bad", ["1", 0, 1.0, None])
def test_override_rejects_non_bool_is_present(bad) -> None:
with pytest.raises(ValueError, match="is_present must be a real bool"):
PresenceOverride(1, "2026-07-20", "2026-07-20", bad)
@pytest.mark.parametrize("bad", [5, ["note"], 1.5])
def test_override_rejects_non_string_note(bad) -> None:
with pytest.raises(ValueError, match="note must be a string or None"):
PresenceOverride(1, "2026-07-20", "2026-07-20", True, note=bad)
@pytest.mark.parametrize("bad", [True, 1.0, "1"])
def test_override_rejects_non_int_child_id(bad) -> None:
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceOverride(bad, "2026-07-20", "2026-07-20", True)
@pytest.mark.parametrize(
("start", "end"), [("2026-7-20", "2026-07-20"), ("nope", "2026-07-20")]
)
def test_override_rejects_non_strict_dates(start, end) -> None:
with pytest.raises(ValueError, match="date"):
PresenceOverride(1, start, end, True)
def test_schedule_rejects_datetime_anchor() -> None:
"""datetime.datetime subclasses date: a bare isinstance check would
store a time component and every date comparison would TypeError."""
with pytest.raises(ValueError, match="plain calendar date"):
PresenceSchedule(1, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}})
def test_override_rejects_datetime_bounds() -> None:
with pytest.raises(ValueError, match="plain calendar date"):
PresenceOverride(
1, datetime.datetime(2026, 7, 20), "2026-07-20", True
)
def test_schedule_pattern_is_immutable_after_construction() -> None:
"""The frozen schedule must not leak a mutable dict: injecting
unvalidated weekdays or dropping required weeks after construction
would bypass total validation."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}, 1: {1}})
with pytest.raises(TypeError):
schedule.pattern[0] = {9}
with pytest.raises(TypeError):
del schedule.pattern[1]
# mappingproxy exposes no mutators at all.
assert not any(
hasattr(schedule.pattern, name)
for name in ("clear", "pop", "popitem", "setdefault", "update")
)
# The validated contents are still readable and encode correctly.
assert schedule.pattern[0] == frozenset({0, 2})
assert schedule.encode() == "0,2|1"
# ---------------------------------------------------------------------------
# Cycle arithmetic: anchor-date only, never ISO week parity
# ---------------------------------------------------------------------------
def test_cycle_week_index_follows_the_anchor_formula() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
# The anchor day starts week 0; each whole 7 days steps the cycle.
offsets = {0: 0, 1: 0, 6: 0, 7: 1, 8: 1, 13: 1, 14: 0, 15: 0, 22: 1}
for offset, expected in offsets.items():
assert (
cycle_week_index(
schedule, ANCHOR + datetime.timedelta(days=offset)
)
== expected
), f"offset {offset}"
def test_cycle_week_index_before_the_anchor_walks_backwards() -> None:
"""Python floor division: day -1 is the LAST week of the cycle,
wrapping backwards — never inverting."""
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=1)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=7)) == 1
assert cycle_week_index(schedule, ANCHOR - datetime.timedelta(days=8)) == 0
def test_cycle_week_index_three_week_pattern() -> None:
schedule = PresenceSchedule(
1, 3, ANCHOR, {0: {0}, 1: {1}, 2: {2}}
)
offsets = [0, 7, 14, 21, 28]
assert [cycle_week_index(schedule, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [
0, 1, 2, 0, 1
]
def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
"""The regression this exists for: walking day by day through a
December 31st -> January 1st boundary, the cycle index steps by 0
or +1 (mod cycle length) and NEVER resets or inverts — including
2020, a 53-ISO-week year, where ISO week parity flips and would
invert a two-week custody schedule on January 1st."""
schedule = PresenceSchedule(
1, 2, datetime.date(2019, 1, 6), {0: {0, 2, 4}, 1: {1, 3}}
)
# Independently derived expectations (anchor 2019-01-06, index =
# floor(days-since-anchor / 7) % 2, hand-verified) — hardcoded so
# even an implementation that resets or inverts at January 1st
# cannot pass by coincidence:
expected = {
# 2019 -> 2020 boundary
datetime.date(2019, 12, 26): 0,
datetime.date(2019, 12, 27): 0,
datetime.date(2019, 12, 28): 0,
datetime.date(2019, 12, 29): 1,
datetime.date(2019, 12, 30): 1,
datetime.date(2019, 12, 31): 1,
datetime.date(2020, 1, 1): 1,
datetime.date(2020, 1, 2): 1,
datetime.date(2020, 1, 3): 1,
datetime.date(2020, 1, 4): 1,
datetime.date(2020, 1, 5): 0,
datetime.date(2020, 1, 6): 0,
datetime.date(2020, 1, 7): 0,
# 2020 (53 ISO weeks) -> 2021 boundary
datetime.date(2020, 12, 27): 1,
datetime.date(2020, 12, 28): 1,
datetime.date(2020, 12, 29): 1,
datetime.date(2020, 12, 30): 1,
datetime.date(2020, 12, 31): 1,
datetime.date(2021, 1, 1): 1,
datetime.date(2021, 1, 2): 1,
datetime.date(2021, 1, 3): 0,
datetime.date(2021, 1, 4): 0,
datetime.date(2021, 1, 5): 0,
datetime.date(2021, 1, 6): 0,
datetime.date(2021, 1, 7): 0,
# 2021 -> 2022 boundary
datetime.date(2021, 12, 27): 1,
datetime.date(2021, 12, 28): 1,
datetime.date(2021, 12, 29): 1,
datetime.date(2021, 12, 30): 1,
datetime.date(2021, 12, 31): 1,
datetime.date(2022, 1, 1): 1,
datetime.date(2022, 1, 2): 0,
datetime.date(2022, 1, 3): 0,
datetime.date(2022, 1, 4): 0,
datetime.date(2022, 1, 5): 0,
datetime.date(2022, 1, 6): 0,
datetime.date(2022, 1, 7): 0,
}
for day, wanted in expected.items():
got = cycle_week_index(schedule, day)
assert got == wanted, f"{day}: expected week {wanted}, got {got}"
# And 2020 really is the 53-ISO-week year this guards against.
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
def test_module_uses_no_iso_week_functions() -> None:
"""D-004 guardrail: cycle math never reads ISO week numbers."""
from pathlib import Path
source = Path(
__import__("custom_components.nestquest.presence", fromlist=["__file__"]).__file__
).read_text()
assert "isocalendar" not in source
assert "isoweek" not in source
# ---------------------------------------------------------------------------
# PresenceEngine: is_present with the always-present default
# ---------------------------------------------------------------------------
def _three_child_engine() -> PresenceEngine:
"""Two children on opposite weeks of one two-week rotation, one
with no schedule at all (the always-present sibling)."""
anchor = datetime.date(2026, 1, 5) # Monday, week 0
declan = PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
jordyn = PresenceSchedule(
2, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
)
return PresenceEngine({1: declan, 2: jordyn})
def test_engine_child_without_schedule_is_always_present() -> None:
"""Every day of the four-week span, all three children get the
expected verdict: Chloe (no schedule) always present; the two
rotation children complementary per the anchor pattern."""
engine = _three_child_engine()
anchor = datetime.date(2026, 1, 5)
for offset in range(28):
day = anchor + datetime.timedelta(days=offset)
week = ((day - anchor).days // 7) % 2
assert engine.is_present(3, day) is True, f"offset {offset}: Chloe"
assert engine.is_present(1, day) is (week == 0), (
f"offset {offset}: Declan should follow week {week}"
)
assert engine.is_present(2, day) is (week == 1), (
f"offset {offset}: Jordyn should follow week {week}"
)
def test_engine_alternating_children_are_complementary_for_four_weeks() -> None:
"""Every day of a four-week span: the two rotation children swap
presence exactly per the anchor pattern — when one is home the
other is away."""
engine = _three_child_engine()
anchor = datetime.date(2026, 1, 5)
for offset in range(28):
day = anchor + datetime.timedelta(days=offset)
d = engine.is_present(1, day)
j = engine.is_present(2, day)
assert d == (not j), f"offset {offset}: {d}/{j} not complementary"
expected_week = ((day - anchor).days // 7) % 2
expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6}
assert engine.is_present(1, day) == (expected_week == 0), (
f"offset {offset}: Declan week {expected_week}"
)
# Spot-check the swap days.
assert engine.is_present(1, anchor) is True
assert engine.is_present(2, anchor) is False
assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False
assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True
def test_engine_weekend_absence_respects_the_pattern() -> None:
"""A weekday-set pattern excludes days outside the set even in the
child's present week."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}}
)
}
)
# 2026-01-06 (Tue) is week 0, weekday 1 -> present.
assert engine.is_present(1, datetime.date(2026, 1, 6)) is True
# 2026-01-07 (Wed) is week 0, weekday 2 -> absent.
assert engine.is_present(1, datetime.date(2026, 1, 7)) is False
# 2026-01-14 (Wed) is week 1, weekday 2 -> present.
assert engine.is_present(1, datetime.date(2026, 1, 14)) is True
def test_engine_rejects_bad_construction_and_inputs() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
with pytest.raises(ValueError, match="must map child_id"):
PresenceEngine([schedule])
with pytest.raises(ValueError, match="must be a PresenceSchedule"):
PresenceEngine({1: "not-a-schedule"})
with pytest.raises(ValueError, match="holds the schedule"):
PresenceEngine({2: schedule})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({True: schedule})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})})
engine = PresenceEngine({1: schedule})
with pytest.raises(ValueError, match="child_id must be an integer"):
engine.is_present(True, ANCHOR)
with pytest.raises(ValueError, match="plain calendar date"):
engine.is_present(1, datetime.datetime(2026, 1, 5))
def test_engine_is_immutable() -> None:
engine = _three_child_engine()
with pytest.raises(AttributeError, match="immutable snapshot"):
engine._schedules = {}
with pytest.raises(AttributeError, match="immutable snapshot"):
engine.something_new = 1
# ---------------------------------------------------------------------------
# Overrides take precedence over the pattern
# ---------------------------------------------------------------------------
def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine:
anchor = datetime.date(2026, 1, 5) # Monday
return PresenceEngine(
{
1: PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
},
overrides={1: list(overrides_for_declan)} if overrides_for_declan else {},
)
def test_absent_override_on_normally_present_day_wins() -> None:
# 2026-01-06 is Tuesday of Declan's week 0: normally present.
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away")
)
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
# The pattern resumes outside the override.
assert engine.is_present(1, datetime.date(2026, 1, 7)) is True
def test_present_override_on_normally_absent_day_wins() -> None:
# 2026-01-13 is in Declan's week 1 (away all week); a present
# override on the Tuesday of week 1 brings him home for that day.
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade")
)
assert engine.is_present(1, datetime.date(2026, 1, 13)) is True
assert engine.is_present(1, datetime.date(2026, 1, 14)) is False
def test_override_applies_without_a_schedule() -> None:
"""An override on a schedule-free child still applies: holidays and
swaps must work for the always-present sibling too."""
engine = PresenceEngine(
{},
overrides={
3: [
PresenceOverride(
3, "2026-07-20", "2026-07-24", False, note="holiday"
)
]
},
)
assert engine.is_present(3, datetime.date(2026, 7, 22)) is False
assert engine.is_present(3, datetime.date(2026, 7, 19)) is True
assert engine.is_present(3, datetime.date(2026, 7, 25)) is True
def test_override_range_beats_pattern_for_its_span() -> None:
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week")
)
for offset in range(12, 17):
day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset)
assert engine.is_present(1, day) is True, f"{day}"
# Days outside the span stay on the pattern (week 1: absent).
assert engine.is_present(1, datetime.date(2026, 1, 12)) is False
assert engine.is_present(1, datetime.date(2026, 1, 18)) is False
def test_engine_override_snapshot_validation() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True)
with pytest.raises(ValueError, match="must map child_id"):
PresenceEngine({1: schedule}, overrides=[entry])
with pytest.raises(ValueError, match="must be a list"):
PresenceEngine({1: schedule}, overrides={1: entry})
with pytest.raises(ValueError, match="must be"):
PresenceEngine({1: schedule}, overrides={1: ["nope"]})
with pytest.raises(ValueError, match="holds an override"):
PresenceEngine({1: schedule}, overrides={2: [entry]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1: schedule}, overrides={True: [entry]})
def test_engine_overlapping_snapshot_uses_latest_start() -> None:
"""The DAO layer refuses overlapping overrides per child; if a
snapshot carries them anyway (hand-built), the latest start date
wins deterministically."""
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-06", "2026-01-10", False),
PresenceOverride(1, "2026-01-08", "2026-01-12", True),
)
# Jan 6-7: only the earlier override covers -> absent.
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
# Jan 8-10: both cover; the LATER start (present) wins.
assert engine.is_present(1, datetime.date(2026, 1, 9)) is True
# Jan 11-12: only the later override covers -> present.
assert engine.is_present(1, datetime.date(2026, 1, 11)) is True
def test_engine_override_lists_are_frozen() -> None:
"""The overrides snapshot must be truly read-only: the engine is
built directly on the CALLER's list, so mutating that list after
construction would change answers under a shallow copy — under the
frozen snapshot it must not."""
entry = PresenceOverride(1, "2026-01-06", "2026-01-06", False)
entries = [entry]
schedule = PresenceSchedule(
1, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
engine = PresenceEngine(
{1: schedule}, overrides={1: entries} # caller-owned list
)
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
entries.append(PresenceOverride(1, "2026-01-07", "2026-01-07", False))
# The late append must NOT change the engine's answers...
assert engine.is_present(1, datetime.date(2026, 1, 7)) is True
# Tuples have no append at all (AttributeError), and the mapping
# itself refuses reassignment.
with pytest.raises(AttributeError):
engine._overrides[1].append(entry)
with pytest.raises(AttributeError, match="immutable snapshot"):
engine._overrides = {}
# ---------------------------------------------------------------------------
# next_present_dates: the parent's pattern preview
# ---------------------------------------------------------------------------
def _preview_engine() -> PresenceEngine:
"""Declan: home all of week 0, away all of week 1 (anchor
2026-01-05, a Monday); an away override Tue Jan 13 (his away week)
would matter only for the override test."""
return PresenceEngine(
{
1: PresenceSchedule(
1, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
}
)
def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None:
engine = _preview_engine()
# Starting Monday Jan 5 (present week 0): five straight days.
preview = engine.next_present_dates(1, "2026-01-05", 5)
assert preview == [
datetime.date(2026, 1, 5),
datetime.date(2026, 1, 6),
datetime.date(2026, 1, 7),
datetime.date(2026, 1, 8),
datetime.date(2026, 1, 9),
]
# Starting mid-week: the rest of week 0 first, then the absent
# week 1 is skipped and the next present week picks up.
preview = engine.next_present_dates(1, "2026-01-07", 3)
assert preview == [
datetime.date(2026, 1, 7),
datetime.date(2026, 1, 8),
datetime.date(2026, 1, 9),
]
preview = engine.next_present_dates(1, "2026-01-07", 6)
# Week 0 runs Mon Jan 5 .. Sun Jan 11 (all present); the away week
# (Jan 12-18) is skipped; week 2 resumes Jan 19.
assert preview == [
datetime.date(2026, 1, 7),
datetime.date(2026, 1, 8),
datetime.date(2026, 1, 9),
datetime.date(2026, 1, 10),
datetime.date(2026, 1, 11),
datetime.date(2026, 1, 19),
]
def test_preview_start_date_is_inclusive() -> None:
engine = _preview_engine()
# Jan 12 (Monday of the away week) is absent: the first present day
# is Jan 19 — the start date itself when present.
preview = engine.next_present_dates(1, "2026-01-19", 1)
assert preview == [datetime.date(2026, 1, 19)]
def test_preview_honours_overrides() -> None:
schedule = PresenceSchedule(
1, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
engine = PresenceEngine(
{1: schedule},
overrides={
1: [
PresenceOverride(
1, "2026-01-05", "2026-01-06", False, note="holiday"
)
]
},
)
# Jan 5-6 blocked by the override: the preview starts Jan 7.
preview = engine.next_present_dates(1, "2026-01-04", 3)
assert preview == [
datetime.date(2026, 1, 7),
datetime.date(2026, 1, 8),
datetime.date(2026, 1, 9),
]
def test_preview_schedule_free_child_returns_consecutive_days() -> None:
engine = PresenceEngine({})
preview = engine.next_present_dates(3, "2026-03-01", 3)
assert preview == [
datetime.date(2026, 3, 1),
datetime.date(2026, 3, 2),
datetime.date(2026, 3, 3),
]
def test_preview_rejects_bad_inputs() -> None:
engine = _preview_engine()
with pytest.raises(ValueError, match="count must be an integer"):
engine.next_present_dates(1, "2026-01-05", "3")
with pytest.raises(ValueError, match="count must be at least 1"):
engine.next_present_dates(1, "2026-01-05", 0)
with pytest.raises(ValueError, match="child_id must be an integer"):
engine.next_present_dates(True, "2026-01-05", 1)
with pytest.raises(ValueError, match="plain calendar date"):
engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1)
def test_preview_always_absent_child_raises_not_loops() -> None:
"""A child absent every scanned day must surface as an error, not
hang the admin preview."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 1, datetime.date(2026, 1, 5), {0: set()}
)
}
)
with pytest.raises(ValueError, match="not present on any"):
engine.next_present_dates(1, "2026-01-05", 2)
# ---------------------------------------------------------------------------
# The week-parity regression guard (Feature 05, D-004)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("year", "length", "reason"),
[
(2020, 366, "leap year, 53 ISO weeks"),
(2015, 365, "365 days, 53 ISO weeks"),
],
)
def test_custody_rotation_walks_full_years_without_inversion(
year, length, reason
) -> None:
"""REGRESSION GUARD for the week-parity bug (D-004).
Walks a two-week alternating rotation day by day across a FULL
{length}-day year ({year} — {reason}) plus the following January,
asserting the present/absent sequence never breaks or inverts at
any point, including January 1st. ISO week parity silently flips
at the ISO-week rollover (the Monday on or after January 4th —
January 4th itself in both of these years, which end ISO week 53)
and would invert every custody schedule there; anchor-date
arithmetic must not.
""".format(length=length, year=year, reason=reason)
if year == 2020:
assert datetime.date(2020, 12, 31).isocalendar()[1] == 53
assert (datetime.date(2021, 1, 1) - datetime.date(2020, 1, 1)).days == 366
if year == 2015:
assert datetime.date(2015, 12, 31).isocalendar()[1] == 53
assert (datetime.date(2016, 1, 1) - datetime.date(2015, 1, 1)).days == 365
# The first Monday of January (2020: Jan 6; 2015: Jan 5) — an
# on-week anchor, whatever the year.
anchor = datetime.date(year, 1, 1) + datetime.timedelta(
days=(7 - datetime.date(year, 1, 1).weekday()) % 7
)
assert anchor.weekday() == 0
rotation = PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
engine = PresenceEngine({1: rotation})
# Walk EVERY day of the year. The expectation is derived from the
# anchor's 7-day BLOCKS, not from the engine: each block of seven
# days starting at the anchor is uniformly present (even block
# index) or uniformly absent (odd block index), and consecutive
# blocks alternate. A parity-based implementation resets on
# January 1st and breaks either the uniformity of a block or the
# alternation between them — most visibly in the block spanning
# December 31st -> January 1st of a 53-ISO-week year.
# Walk the FULL year AND the first two weeks of the following
# January: the block spanning December 31st -> January 1st must
# stay uniform ACROSS the boundary, so an implementation that
# resets when the target year differs from the anchor's fails
# here (the post-boundary days of its final block disagree).
first_day = datetime.date(year, 1, 1)
last_day = datetime.date(year + 1, 1, 14)
days_seen = 0
day = first_day
while day <= last_day:
days_since_anchor = (day - anchor).days
block = days_since_anchor // 7 # which 7-day block of the cycle
block_start = anchor + datetime.timedelta(days=block * 7)
# Uniformity within the block: every day of this 7-day block
# must answer exactly like the block's first day.
expected = ((block % 2) == 0)
assert engine.is_present(1, day) is expected, (
f"{day} (block {block}, day offset "
f"{(day - block_start).days}): present="
f"{engine.is_present(1, day)}, expected {expected}"
)
# The block's OTHER days must agree — including the days on
# the far side of January 1st.
for offset in range(7):
block_day = block_start + datetime.timedelta(days=offset)
if first_day <= block_day <= last_day:
assert engine.is_present(1, block_day) is expected, (
f"{block_day} disagrees with its block {block}"
)
days_seen += 1
day += datetime.timedelta(days=1)
# The walked year itself contributed exactly its full length.
year_days = sum(
1
for offset in range((last_day - first_day).days + 1)
if (first_day + datetime.timedelta(days=offset)).year == year
)
assert year_days == length, (
f"walked {year_days} days of {year}, expected {length}"
)
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
("event_type", "TEXT", 1, None, 0),
("actor_source", "TEXT", 1, None, 0),
("actor_user_id", "TEXT", 0, None, 0),
("actor_child_id", "INTEGER", 0, None, 0),
("occurred_at", "TEXT", 1, None, 0),
("was_on_time", "INTEGER", 0, None, 0),
]
@@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
assert declared == {
("quest_instances", "instance_id", "id"),
("children", "child_id", "id"),
("children", "actor_child_id", "id"),
}
finally:
_run(database.close())
@@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=1,
was_on_time=0,
)
row = _run(
database.fetch_one(
"SELECT actor_source, actor_user_id, was_on_time "
"FROM completion_events WHERE id = ?",
"SELECT actor_source, actor_user_id, actor_child_id, "
"was_on_time FROM completion_events WHERE id = ?",
(event_id,),
)
)
assert row == ("panel", None, 0)
assert row == ("panel", None, 1, 0)
finally:
_run(database.close())
@pytest.mark.parametrize(
("actor_source", "actor_user_id", "actor_child_id"),
[
("user", "user-1", 1), # admin event must not carry a profile
("panel", None, None), # panel event requires the profile
("panel", "user-1", 1), # panel must not carry a user id either
("panel", "user-1", None),
("user", None, None),
],
)
def test_completion_events_actor_child_coherence_enforced(
tmp_path, actor_source, actor_user_id, actor_child_id
) -> None:
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
actor_child_id; 'panel' events carry actor_child_id and no
actor_user_id. The CHECKs make both halves non-negotiable."""
database = _open_db(tmp_path / "completion-events-coherence.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_event(
database,
instance_id=instance_id,
event_type="completed",
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=1,
)
finally:
_run(database.close())
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_event(
database,
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=999,
was_on_time=1,
)
finally:
_run(database.close())
+41
View File
@@ -0,0 +1,41 @@
"""Tests for the NestQuest services.yaml declaration file."""
from __future__ import annotations
import re
from pathlib import Path
SERVICES_PATH = (
Path(__file__).parent.parent
/ "custom_components"
/ "nestquest"
/ "services.yaml"
)
def test_services_yaml_exists() -> None:
assert SERVICES_PATH.exists(), f"services.yaml missing at {SERVICES_PATH}"
def test_services_yaml_declares_regenerate() -> None:
text = SERVICES_PATH.read_text(encoding="utf-8")
assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), (
"services.yaml must declare the 'regenerate' service"
)
def test_services_yaml_regenerate_has_description_and_no_fields() -> None:
"""The regenerate service is documented with a clear description and no
required fields (there is no ``fields`` block under it)."""
text = SERVICES_PATH.read_text(encoding="utf-8")
match = re.search(r"^regenerate:\s*$", text, re.MULTILINE)
assert match is not None
section = text[match.end():]
next_service = re.search(r"^(?![#\s])\w+:\s*$", section, re.MULTILINE)
if next_service is not None:
section = section[: next_service.start()]
assert re.search(r"description:", section), (
"regenerate must carry a description"
)
assert "fields:" not in section, (
"regenerate declares no required fields"
)
+370
View File
@@ -0,0 +1,370 @@
"""Timezone tests for the single HA-local date source in the generation path.
These pin the done-condition that every "today" read in the generation path
derives from ``hass.config.time_zone`` — not the host clock and not UTC —
and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the
no-past guard resolve the correct local dates around midnight and across the
daylight-saving transitions.
"""
from __future__ import annotations
import asyncio
import datetime
from types import SimpleNamespace
from zoneinfo import ZoneInfo
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase
from custom_components.nestquest.migrations import apply_migrations
from custom_components.nestquest.quest_definitions import (
assign_child,
create_quest_definition,
edit_quest_definition,
)
from custom_components.nestquest.recurrence import RuleType, ScheduleRule
NOW = "2026-09-14T12:00:00+00:00"
NEW_YORK = "America/New_York"
def _make_hass_mock():
from unittest.mock import AsyncMock, MagicMock
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
async def _prepare(path):
database = NestQuestDatabase(_make_hass_mock())
await database.open(path)
await apply_migrations(database)
return database
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _freeze_clock(
monkeypatch, instant: datetime.datetime
) -> tuple[datetime.datetime, list]:
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
the generation path's "today" reads this instant (localized to whatever
time zone it asks for) while every other module and the test harness keep
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
objects the code under test passed to ``now`` — so a test can prove the
generation path actually requested the configured HA time zone rather
than silently reading UTC.
"""
import custom_components.nestquest as nestquest
frozen = instant.astimezone(datetime.timezone.utc)
requested: list = []
class _FrozenDatetime(datetime.datetime):
@classmethod
def now(cls, tz=None):
requested.append(tz)
if tz is None:
return frozen.replace(tzinfo=None)
return frozen.astimezone(tz)
fake_module = SimpleNamespace(
datetime=_FrozenDatetime,
timedelta=datetime.timedelta,
date=datetime.date,
)
monkeypatch.setattr(nestquest, "datetime", fake_module)
return frozen, requested
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
"""Assert ``now`` was called with (only) the configured HA time zone.
This is what turns the DST tests into a real proof of the HA-local date
source: if the generation path regressed to ``now(timezone.utc)``, its
requested tz would be UTC and this fails.
"""
assert requested, "the generation path never called now(tz)"
assert all(tz == ZoneInfo(time_zone) for tz in requested)
def _tz_hass(time_zone: str):
"""A minimal hass exposing only the ``config.time_zone`` the walk reads."""
return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone))
async def _seed_daily(database, start_iso: str) -> int:
"""One always-present child + a daily definition starting ``start_iso``."""
child = await ChildrenDao(database).create("Ada", NOW)
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
return child.id
def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date:
return instant_utc.astimezone(ZoneInfo(time_zone)).date()
def _assert_contiguous_horizon(records, local_today: datetime.date) -> None:
"""The horizon is exactly today..today+horizon, no off-by-one, no gap."""
expected = {
(local_today + datetime.timedelta(days=offset)).isoformat()
for offset in range(DEFAULT_HORIZON_DAYS + 1)
}
assert {r.due_date for r in records} == expected
assert len(records) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_newyork_local_date_after_local_midnight(
tmp_path, monkeypatch
) -> None:
"""A run just after New York local midnight uses the local date.
The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so
04:05 UTC). The horizon must start on 2026-03-10 — the local calendar
date — not any host/UTC-derived value.
"""
instant = datetime.datetime(
2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK)
)
local_today = instant.date()
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-midnight.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_uses_local_date_not_utc_when_host_ahead(
tmp_path, monkeypatch
) -> None:
"""The walk's "today" is HA-local, never the UTC/system calendar date.
FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but
New York local is still 2026-01-14 22:00 EST. The horizon must start on
2026-01-14 (local), proving the UTC date did not win.
"""
instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc)
local_today = _local_date(instant, NEW_YORK)
assert local_today == datetime.date(2026, 1, 14)
assert instant.date() == datetime.date(2026, 1, 15)
async def _body(database):
hass = _tz_hass(NEW_YORK)
_frozen, requested = _freeze_clock(monkeypatch, instant)
import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
_assert_requested_ha_timezone(requested, NEW_YORK)
return len(records)
async def _main():
database = await _prepare(tmp_path / "tz-host-ahead.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1
def test_horizon_resolves_spring_forward_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-03-08 spring-forward, today and the horizon stay local.
Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to
2026-03-08 local — the lost hour must not skip or repeat a calendar day —
and the full horizon remains 15 contiguous days.
"""
zone = ZoneInfo(NEW_YORK)
before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST
after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT
assert before.date() == after.date() == datetime.date(2026, 3, 8)
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (before, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-spring-forward.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 3, 8)
def test_horizon_resolves_fall_back_day_without_off_by_one(
tmp_path, monkeypatch
) -> None:
"""Across the 2026-11-01 fall-back, today and the horizon stay local.
The repeated 01:00-02:00 hour must not resolve to the wrong calendar
day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30
(``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01
— and both, along with 03:30 EST after the jump, materialize the same
15 contiguous-day horizon with no off-by-one.
"""
zone = ZoneInfo(NEW_YORK)
first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT
second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST
after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST
assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1)
assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds
async def _body(database):
hass = _tz_hass(NEW_YORK)
import custom_components.nestquest as nestquest
requested_batches: list[list] = []
for instant in (first, second, after):
_frozen, requested = _freeze_clock(monkeypatch, instant)
requested_batches.append(requested)
local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database)
records = await QuestInstancesDao(database).list_by_date_range(
child_id,
local_today.isoformat(),
(local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(),
)
_assert_contiguous_horizon(records, local_today)
requested_all = [tz for batch in requested_batches for tz in batch]
_assert_requested_ha_timezone(requested_all, NEW_YORK)
return local_today
async def _main():
database = await _prepare(tmp_path / "tz-fall-back.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) == datetime.date(2026, 11, 1)
def test_definition_business_regeneration_uses_pinned_today_not_host(
tmp_path, monkeypatch
) -> None:
"""``edit_quest_definition``/``assign_child`` anchor regeneration on
the caller-pinned HA-local ``today``, not the host clock.
The host clock (the ``_today`` fallback) is pinned one day AHEAD of the
HA-local ``today``; the guard would reject the local "today" as past
(and the horizon would skip it) if it consulted the host clock. Both
business operations must regenerate against the pinned date and keep
that first local day, proving the threaded ``today`` wins.
"""
import custom_components.nestquest.dao_instances as dao_instances_module
pinned = datetime.date(2026, 1, 14)
host_today = datetime.date(2026, 1, 15)
monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today)
async def _body(database):
children = ChildrenDao(database)
dao = QuestInstancesDao(database)
horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)
a = await children.create("Ada", NOW)
created = await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()),
[a.id],
[("morning", "09:00")],
)
definition_id = created.definition.id
# edit anchors on pinned: the horizon starts at the local day the
# host clock calls "past", so its presence proves pinned won.
await edit_quest_definition(
database,
definition_id,
windows=[("morning", "10:15")],
today=pinned,
)
a_records = await dao.list_by_date_range(
a.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in a_records][0] == pinned.isoformat()
assert {r.due_time for r in a_records} == {"10:15"}
assert len(a_records) == DEFAULT_HORIZON_DAYS + 1
# assign anchors on pinned too: the new child's regeneration keeps
# the same pinned-local first day rather than skipping to the host day.
b = await children.create("Bo", NOW)
await assign_child(database, definition_id, b.id, today=pinned)
b_records = await dao.list_by_date_range(
b.id, pinned.isoformat(), horizon_end.isoformat()
)
assert [r.due_date for r in b_records][0] == pinned.isoformat()
assert len(b_records) == DEFAULT_HORIZON_DAYS + 1
return definition_id
async def _main():
database = await _prepare(tmp_path / "tz-definition-regen.db")
try:
return await _body(database)
finally:
await database.close()
assert _run(_main()) is not None