Feature: multiple presence patterns per child (schema 9) (#232)

Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
This commit was merged in pull request #232.
This commit is contained in:
2026-09-24 20:15:27 -04:00
committed by approver_agent
parent d3c52974f5
commit c313e1c56f
33 changed files with 4157 additions and 1381 deletions
+584 -144
View File
@@ -1,4 +1,4 @@
"""Admin-plane presence route tests (task e4a9d31b).
"""Admin-plane presence route tests (task e4a9d31b; schema 9 patterns).
These tests exercise the admin plane's presence routes against the ASGI
app with httpx, reusing the shared local-RSA-key/stubbed-JWKS harness
@@ -11,31 +11,38 @@ read through the SAME core copy the app uses (``nestquest_core.*``; see
the two-copy caveat in api/nestquest_core.py):
- Authentication runs through the router's ONE ``require_admin``
dependency: an admin JWT passes, a JWT without the group is 403, an
absent credential is 401 — and nothing is written.
- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the
first set persists the schedule, setting again REPLACES it — one
schedule row per child, never a duplicate.
dependency on EVERY presence route (the four pattern routes and the
override routes): an admin JWT passes, a JWT without the group or the
panel service token is 403, an absent credential is 401 — and
nothing is written.
- ``POST /api/v1/admin/children/{id}/presence-patterns`` ADDS a
pattern: a child has zero or more, each create inserts a new row
(never replacing an earlier one). ``PATCH
/api/v1/admin/presence-patterns/{id}`` changes only the supplied
fields; ``DELETE`` removes the one pattern. The removed one-per-child
``/presence-schedule`` route no longer exists.
- ``POST /api/v1/admin/presence-overrides`` creates the override (its
``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}``
removes it again.
- The child's RESOLVED presence changes accordingly: the pure
:class:`~nestquest_core.presence.PresenceEngine` built from the
stored rows answers differently before/after each write — a schedule
restricts the child's present weekdays, an override beats the pattern
for its range, and deleting the override restores the pattern's
answer.
stored rows answers differently before/after each write — a home
pattern restricts the child's present weekdays, an away pattern beats
a home one, an override beats every pattern for its range, and
deleting the override (or the pattern) restores the earlier answer.
- The regeneration side effect lives in the core layer the route
calls: setting an all-absent schedule through the route deletes the
child's future open quest instances and they are NOT rebuilt while
the child is absent every day — the same proof the HA service test
makes, through the API instead.
calls: adding an all-absent home pattern through the route deletes
the child's future open quest instances and they are NOT rebuilt
while the child is absent every day; deleting that pattern rebuilds
them — the same proof the HA service test makes, through the API.
Error mapping (documented in api/routes_admin.py): a core ValueError
for a non-existent child (schedule or override create) or override
(delete) is 404; every other core ValueError — a rejected schedule
(cycle length, pattern coverage, anchor date) or override (malformed or
inverted dates, an overlapping range) — is 422, the same failure class
for a non-existent child (pattern or override create) is 404 "Child not
found", a non-existent pattern (edit/delete) 404 "Presence pattern not
found", a non-existent override (delete) 404; every other core
ValueError — a rejected pattern (name, kind, cycle length, pattern
coverage, anchor date) or override (malformed or inverted dates, an
overlapping range) — is 422 naming the field, the same failure class
FastAPI reports for malformed bodies.
"""
from __future__ import annotations
@@ -64,24 +71,54 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
_materialize = importlib.import_module("nestquest_core.materialize")
_presence = importlib.import_module("nestquest_core.presence")
#: A Monday anchoring every schedule: cycle week 0 starts here.
#: A Monday anchoring every pattern: cycle week 0 starts here.
ANCHOR_MONDAY = "2026-01-05"
#: The Tuesday of that week — absent under the test schedule.
#: The Tuesday of that week — absent under the test home pattern.
TUESDAY = "2026-01-06"
#: The panel service token the harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
#: The 403 detail for the panel service token on an admin route.
PANEL_TOKEN_REJECTION_DETAIL = (
"This credential type is not accepted for admin access"
)
#: The 403 detail for a valid JWT without the admin group.
FORBIDDEN_DETAIL = (
"Admin access requires membership in the nestquest-admins group"
)
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
def _schedule_body(
cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None
def _patterns_url(child_id: int) -> str:
"""The child-scoped pattern list/create route."""
return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _pattern_url(pattern_id: int | str) -> str:
"""The id-scoped pattern edit/delete route."""
return f"/api/v1/admin/presence-patterns/{pattern_id}"
def _pattern_body(
cycle_length_weeks: object = 1,
pattern: dict[str, object] | None = None,
*,
name: object = "Mum's house",
kind: object = "home",
anchor_date: object = ANCHOR_MONDAY,
) -> dict[str, object]:
"""A valid schedule body anchored to the test Monday."""
"""A valid pattern body anchored to the test Monday (Mon/Wed/Fri home)."""
return {
"name": name,
"kind": kind,
"cycle_length_weeks": cycle_length_weeks,
"anchor_date": ANCHOR_MONDAY,
"anchor_date": anchor_date,
"pattern": {"0": [0, 2, 4]} if pattern is None else pattern,
}
@@ -103,6 +140,18 @@ def _override_body(
}
def _detail_names(response: object, field: str) -> bool:
"""True when a 422's detail names ``field``.
A core ValueError arrives as a string detail naming the field; a
Pydantic shape failure as a list of errors whose ``loc`` holds it.
"""
detail = response.json()["detail"]
if isinstance(detail, str):
return field in detail
return any(field in [str(part) for part in error["loc"]] for error in detail)
async def _create_child(client: object, name: str = "Ada") -> int:
"""Create one child through the admin children route; return the id."""
created = await client.post(
@@ -114,26 +163,44 @@ async def _create_child(client: object, name: str = "Ada") -> int:
return created.json()["id"]
async def _create_pattern(
client: object, child_id: int, body: dict[str, object] | None = None
) -> dict[str, object]:
"""Create one pattern through the admin route; return the payload."""
created = await client.post(
_patterns_url(child_id),
json=_pattern_body() if body is None else body,
headers=_admin_headers(),
)
assert created.status_code == 201, created.text
return created.json()
def _engine(
database: object,
child_id: int,
schedule_record: object | None,
pattern_records: list[object],
override_records: list[object],
) -> object:
"""Build the pure PresenceEngine from stored rows (the snapshot shape).
Mirrors what the core snapshot builder assembles: the child's
decoded schedule (absent → present every day) plus its overrides,
decoded patterns (none → present every day) plus its overrides,
so :meth:`PresenceEngine.is_present` answers from the SAME rows the
routes produced.
"""
schedules: dict[int, object] = {}
if schedule_record is not None:
schedules[child_id] = _presence.PresenceSchedule.decode(
schedule_record.child_id,
schedule_record.anchor_date,
schedule_record.pattern,
)
patterns = {
child_id: [
_presence.PresencePattern.decode(
record.child_id,
record.name,
record.kind,
record.anchor_date,
record.pattern,
)
for record in pattern_records
]
}
overrides = {
child_id: [
_presence.PresenceOverride(
@@ -146,21 +213,21 @@ def _engine(
for record in override_records
]
}
return _presence.PresenceEngine(schedules, overrides)
return _presence.PresenceEngine(patterns, overrides)
async def _child_state(
database: object, child_id: int
) -> tuple[object | None, list[object], object]:
"""The child's stored schedule record, overrides and PresenceEngine."""
schedule_record = await _dao_presence.PresenceSchedulesDao(
) -> tuple[list[object], list[object], object]:
"""The child's stored pattern records, overrides and PresenceEngine."""
pattern_records = await _dao_presence.PresencePatternsDao(
database
).get_by_child(child_id)
).list_by_child(child_id)
override_records = await _dao_presence.PresenceOverridesDao(
database
).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01")
engine = _engine(database, child_id, schedule_record, override_records)
return schedule_record, override_records, engine
engine = _engine(database, child_id, pattern_records, override_records)
return pattern_records, override_records, engine
@pytest.fixture
@@ -192,15 +259,13 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
non_admin = make_token(groups=("some-other-group",))
non_admin_headers = {"Authorization": f"Bearer {non_admin}"}
forbidden_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
forbidden_pattern = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=non_admin_headers,
)
assert forbidden_schedule.status_code == 403
assert forbidden_schedule.json()["detail"] == (
"Admin access requires membership in the nestquest-admins group"
)
assert forbidden_pattern.status_code == 403
assert forbidden_pattern.json()["detail"] == FORBIDDEN_DETAIL
forbidden_override = await client.post(
"/api/v1/admin/presence-overrides",
@@ -214,11 +279,10 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
)
assert forbidden_delete.status_code == 403
unauthenticated_schedule = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
unauthenticated_pattern = await client.post(
_patterns_url(child_id), json=_pattern_body()
)
assert unauthenticated_schedule.status_code == 401
assert unauthenticated_pattern.status_code == 401
unauthenticated_override = await client.post(
"/api/v1/admin/presence-overrides", json=_override_body(child_id)
@@ -230,113 +294,225 @@ async def test_presence_routes_refuse_non_admin_and_absent_credentials(
)
assert unauthenticated_delete.status_code == 401
# No route ran its handler: the child still has no schedule and no
# overrides (and is present every day, the no-schedule default).
_schedule, _overrides, engine = await _child_state(
# No route ran its handler: the child still has no pattern and no
# overrides (and is present every day, the no-pattern default).
patterns, overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert _schedule is None
assert _overrides == []
assert patterns == []
assert overrides == []
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
# --- schedule upsert --------------------------------------------------------
def _non_admin_headers() -> dict[str, str]:
return {
"Authorization": (
f"Bearer {make_token(groups=('some-other-group',))}"
)
}
async def test_schedule_upserts_and_replaces_one_row_per_child(
_CREDENTIALS = {
"absent": (lambda: {}, 401, None),
"panel-token": (
lambda: {"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
403,
PANEL_TOKEN_REJECTION_DETAIL,
),
"non-admin-jwt": (_non_admin_headers, 403, FORBIDDEN_DETAIL),
}
@pytest.mark.parametrize("credential", sorted(_CREDENTIALS))
@pytest.mark.parametrize("route", ["list", "create", "edit", "delete"])
async def test_pattern_routes_enforce_admin_auth(
admin_presence: SimpleNamespace, route: str, credential: str
) -> None:
"""Every pattern route rejects absent, panel and non-admin credentials.
Each refused request leaves the stored pattern exactly as it was: a
refused create adds no row, a refused edit changes nothing, a
refused delete removes nothing.
"""
client = admin_presence.client
child_id = await _create_child(client)
existing = await _create_pattern(client, child_id)
make_headers, status, detail = _CREDENTIALS[credential]
if route == "list":
response = await client.get(
_patterns_url(child_id), headers=make_headers()
)
elif route == "create":
response = await client.post(
_patterns_url(child_id),
json=_pattern_body(name="Dad's house"),
headers=make_headers(),
)
elif route == "edit":
response = await client.patch(
_pattern_url(existing["id"]),
json={"name": "Renamed"},
headers=make_headers(),
)
else:
response = await client.delete(
_pattern_url(existing["id"]), headers=make_headers()
)
assert response.status_code == status
if detail is not None:
assert response.json()["detail"] == detail
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [existing]}
# --- the removed one-per-child schedule route ----------------------------------
async def test_old_presence_schedule_route_is_gone(
admin_presence: SimpleNamespace,
) -> None:
"""PUT /presence-schedule persists, and setting again REPLACES it."""
"""PUT/GET /presence-schedule no longer exist; nothing is written."""
client = admin_presence.client
child_id = await _create_child(client)
url = f"/api/v1/admin/children/{child_id}/presence-schedule"
put = await client.put(
url,
json={
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0]},
},
headers=_admin_headers(),
)
assert put.status_code in (404, 405)
get = await client.get(url, headers=_admin_headers())
assert get.status_code in (404, 405)
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
# --- pattern create ---------------------------------------------------------
async def test_pattern_create_persists_and_adds_a_row_per_create(
admin_presence: SimpleNamespace,
) -> None:
"""POST /presence-patterns persists; a second POST ADDS another row."""
client = admin_presence.client
child_id = await _create_child(client)
first = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
first = await client.post(
_patterns_url(child_id),
json=_pattern_body(),
headers=_admin_headers(),
)
assert first.status_code == 200
assert first.json() == {
assert first.status_code == 201
first_payload = first.json()
assert first_payload == {
"id": first_payload["id"],
"child_id": child_id,
"name": "Mum's house",
"kind": "home",
"cycle_length_weeks": 1,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [0, 2, 4]},
}
# Database proof: exactly ONE schedule row (the schema's
# UNIQUE(child_id) makes a second row unrepresentable), stored as
# encoded CSV.
schedule, _overrides, _engine = await _child_state(
# Database proof: ONE pattern row, stored as encoded CSV.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert schedule is not None
row_id = schedule.id
assert schedule.cycle_length_weeks == 1
assert schedule.anchor_date == ANCHOR_MONDAY
assert schedule.pattern == "0,2,4"
assert [record.id for record in patterns] == [first_payload["id"]]
assert patterns[0].name == "Mum's house"
assert patterns[0].kind == "home"
assert patterns[0].cycle_length_weeks == 1
assert patterns[0].anchor_date == ANCHOR_MONDAY
assert patterns[0].pattern == "0,2,4"
# Setting again UPSERTS: the SAME row is updated in place — new
# values, unchanged id — never a duplicate or a stale first set.
second = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(
cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]}
# Creating again ADDS a second pattern beside the first — the first
# row is untouched (schema 9 has no one-per-child constraint).
second = await client.post(
_patterns_url(child_id),
json=_pattern_body(
cycle_length_weeks=2,
pattern={"0": [], "1": [3, 1]},
name=" Holiday rota ",
kind="away",
),
headers=_admin_headers(),
)
assert second.status_code == 200
assert second.json() == {
assert second.status_code == 201
second_payload = second.json()
assert second_payload == {
"id": second_payload["id"],
"child_id": child_id,
"name": "Holiday rota",
"kind": "away",
"cycle_length_weeks": 2,
"anchor_date": ANCHOR_MONDAY,
"pattern": {"0": [], "1": [1, 3]},
}
schedule, _overrides, _engine = await _child_state(
assert second_payload["id"] != first_payload["id"]
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert schedule is not None
assert schedule.id == row_id
assert schedule.cycle_length_weeks == 2
assert schedule.pattern == "|1,3"
assert [record.id for record in patterns] == [
first_payload["id"],
second_payload["id"],
]
assert patterns[0].pattern == "0,2,4"
assert patterns[1].pattern == "|1,3"
async def test_schedule_changes_the_childs_resolved_presence(
async def test_pattern_changes_the_childs_resolved_presence(
admin_presence: SimpleNamespace,
) -> None:
"""The resolved presence (PresenceEngine) follows the stored schedule."""
"""The resolved presence (PresenceEngine) follows the stored patterns."""
client = admin_presence.client
child_id = await _create_child(client)
# Before any schedule the child is present every day.
_schedule, _overrides, engine = await _child_state(
# Before any pattern the child is present every day.
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is True
# Mon/Wed/Fri only, week 0 of a one-week cycle.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert response.status_code == 200
_schedule, _overrides, engine = await _child_state(
# Home Mon/Wed/Fri only, week 0 of a one-week cycle.
await _create_pattern(client, child_id)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
# An away pattern covering Monday beats the home pattern there.
await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
assert engine.is_present(child_id, "2026-01-07") is True # Wednesday
assert engine.is_present(child_id, TUESDAY) is False
async def test_schedule_upsert_regenerates_future_instances(
async def test_pattern_writes_regenerate_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""Setting an all-absent schedule deletes the child's future instances.
"""An all-absent home pattern deletes the child's future instances.
The regeneration side effect lives in the core layer the route
calls: the child's open instances at or after today are deleted
across ALL its definitions and NOT rebuilt while the child is
absent every day — the same proof the HA service test makes,
exercised here through the API route instead.
absent every day; deleting the pattern rebuilds them.
"""
client = admin_presence.client
database = admin_presence.database
@@ -360,16 +536,257 @@ async def test_schedule_upsert_regenerates_future_instances(
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end)
# The route's write must regenerate: absent every day removes every
# open future instance.
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(pattern={"0": []}),
# The route's write must regenerate: a home pattern covering no day
# makes the child absent every day and removes every open instance.
pattern = await _create_pattern(
client, child_id, _pattern_body(pattern={"0": []})
)
assert await instances.list_by_date_range(child_id, start, end) == []
# Deleting it regenerates too: present every day again, rebuilt.
deleted = await client.delete(
_pattern_url(pattern["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
async def test_pattern_edit_regenerates_future_instances(
admin_presence: SimpleNamespace,
) -> None:
"""PATCH regenerates: flipping an empty home pattern to away rebuilds."""
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
created = await client.post(
"/api/v1/admin/quest-definitions",
json={
"title": "Brush teeth",
"rule": {"rule_type": "daily"},
"assignee_child_ids": [child_id],
"windows": ["morning"],
},
headers=_admin_headers(),
)
assert response.status_code == 200
assert created.status_code == 201
today = datetime.date.today()
start = today.isoformat()
end = (today + datetime.timedelta(days=14)).isoformat()
pattern = await _create_pattern(
client, child_id, _pattern_body(pattern={"0": []})
)
await _materialize.materialize(database, start, end, today=today)
instances = _dao_instances.QuestInstancesDao(database)
assert await instances.list_by_date_range(child_id, start, end) == []
# An away pattern covering no day leaves no home pattern: present
# every day, so the edit's regeneration builds the instances.
edited = await client.patch(
_pattern_url(pattern["id"]),
json={"kind": "away"},
headers=_admin_headers(),
)
assert edited.status_code == 200
assert await instances.list_by_date_range(child_id, start, end)
# --- pattern edit (PATCH) ------------------------------------------------------
async def test_pattern_patch_changes_only_supplied_fields(
admin_presence: SimpleNamespace,
) -> None:
"""A partial PATCH changes the supplied fields and keeps the rest."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
sibling = await _create_pattern(
client, child_id, _pattern_body(name="Sibling", kind="away")
)
renamed = await client.patch(
_pattern_url(original["id"]),
json={"name": "Weekday home"},
headers=_admin_headers(),
)
assert renamed.status_code == 200
assert renamed.json() == dict(original, name="Weekday home")
reshaped = await client.patch(
_pattern_url(original["id"]),
json={"cycle_length_weeks": 2, "pattern": {"0": [1], "1": [6, 5]}},
headers=_admin_headers(),
)
assert reshaped.status_code == 200
assert reshaped.json() == dict(
original,
name="Weekday home",
cycle_length_weeks=2,
pattern={"0": [1], "1": [5, 6]},
)
moved = await client.patch(
_pattern_url(original["id"]),
json={"anchor_date": "2026-01-12", "kind": "away"},
headers=_admin_headers(),
)
assert moved.status_code == 200
expected = dict(
reshaped.json(), anchor_date="2026-01-12", kind="away"
)
assert moved.json() == expected
# Database proof: the edited row, same id, and the sibling untouched.
patterns, _overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [
original["id"],
sibling["id"],
]
assert patterns[0].name == "Weekday home"
assert patterns[0].kind == "away"
assert patterns[0].cycle_length_weeks == 2
assert patterns[0].anchor_date == "2026-01-12"
assert patterns[0].pattern == "1|5,6"
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [expected, sibling]}
async def test_pattern_patch_unknown_id_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Editing a pattern id that does not exist is 404."""
response = await admin_presence.client.patch(
_pattern_url(999), json={"name": "Nope"}, headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.patch(
_pattern_url("not-an-id"),
json={"name": "Nope"},
headers=_admin_headers(),
)
assert malformed.status_code == 422
@pytest.mark.parametrize(
("edit", "field"),
[
({"name": ""}, "name"),
({"name": " "}, "name"),
({"name": None}, "name"),
({"kind": "sometimes"}, "kind"),
({"kind": None}, "kind"),
({"cycle_length_weeks": 0}, "cycle_length_weeks"),
({"cycle_length_weeks": 5}, "cycle_length_weeks"),
({"cycle_length_weeks": 2.5}, "cycle_length_weeks"),
({"cycle_length_weeks": "2"}, "cycle_length_weeks"),
({"cycle_length_weeks": None}, "cycle_length_weeks"),
# A longer cycle without a matching pattern leaves week 1 uncovered.
({"cycle_length_weeks": 2}, "pattern"),
({"anchor_date": "2026-13-40"}, "anchor_date"),
({"anchor_date": None}, "anchor_date"),
({"pattern": {}}, "pattern"),
({"pattern": None}, "pattern"),
({"pattern": {"0": [7]}}, "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
({"pattern": {"0": [True]}}, "pattern"),
({"pattern": {"0": [False]}}, "pattern"),
({"pattern": {"0": ["1"]}}, "pattern"),
({"pattern": {"0": [1.0]}}, "pattern"),
],
)
async def test_rejected_pattern_patch_is_422_and_changes_nothing(
admin_presence: SimpleNamespace, edit: dict[str, object], field: str
) -> None:
"""A rejected edit (incl. explicit null) is 422 naming the field."""
client = admin_presence.client
child_id = await _create_child(client)
original = await _create_pattern(client, child_id)
response = await client.patch(
_pattern_url(original["id"]), json=edit, headers=_admin_headers()
)
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
listed = await client.get(_patterns_url(child_id), headers=_admin_headers())
assert listed.json() == {"patterns": [original]}
# --- pattern delete ------------------------------------------------------------
async def test_pattern_delete_removes_one_pattern_and_presence_resolves(
admin_presence: SimpleNamespace,
) -> None:
"""DELETE removes the one pattern; its siblings and presence follow."""
client = admin_presence.client
child_id = await _create_child(client)
home = await _create_pattern(client, child_id)
away = await _create_pattern(
client,
child_id,
_pattern_body(pattern={"0": [0]}, name="Swim camp", kind="away"),
)
_patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert engine.is_present(child_id, ANCHOR_MONDAY) is False
deleted = await client.delete(
_pattern_url(away["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
assert deleted.json() == {"status": "ok"}
assert (
await _dao_presence.PresencePatternsDao(admin_presence.database).get(
away["id"]
)
is None
)
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in patterns] == [home["id"]]
assert engine.is_present(child_id, ANCHOR_MONDAY) is True
assert engine.is_present(child_id, TUESDAY) is False
# Deleting the last pattern: present every day again.
deleted = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert deleted.status_code == 200
patterns, _overrides, engine = await _child_state(
admin_presence.database, child_id
)
assert patterns == []
assert engine.is_present(child_id, TUESDAY) is True
# A second delete of the same id is 404: it no longer exists.
again = await client.delete(
_pattern_url(home["id"]), headers=_admin_headers()
)
assert again.status_code == 404
assert again.json()["detail"] == "Presence pattern not found"
async def test_delete_unknown_pattern_is_404(
admin_presence: SimpleNamespace,
) -> None:
"""Deleting a pattern id that does not exist is 404."""
response = await admin_presence.client.delete(
_pattern_url(999), headers=_admin_headers()
)
assert response.status_code == 404
assert response.json()["detail"] == "Presence pattern not found"
malformed = await admin_presence.client.delete(
_pattern_url("not-an-id"), headers=_admin_headers()
)
assert malformed.status_code == 422
# --- override create + delete -----------------------------------------------
@@ -381,12 +798,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
client = admin_presence.client
database = admin_presence.database
child_id = await _create_child(client)
schedule_response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
json=_schedule_body(),
headers=_admin_headers(),
)
assert schedule_response.status_code == 200
await _create_pattern(client, child_id)
# Create: the override comes back with its id and the posted shape.
created = await client.post(
@@ -412,7 +824,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
# Database proof: the row exists — and the RESOLVED presence for the
# affected date flips: the override beats the Tuesday-absent pattern.
_schedule, overrides, engine = await _child_state(database, child_id)
_patterns, overrides, engine = await _child_state(database, child_id)
assert [record.id for record in overrides] == [payload["id"]]
assert engine.is_present(child_id, TUESDAY) is True
# A date the override does not cover keeps the pattern's answer.
@@ -430,7 +842,7 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
await _dao_presence.PresenceOverridesDao(database).get(payload["id"])
is None
)
_schedule, overrides, engine = await _child_state(database, child_id)
_patterns, overrides, engine = await _child_state(database, child_id)
assert overrides == []
assert engine.is_present(child_id, TUESDAY) is False
@@ -438,19 +850,19 @@ async def test_override_is_created_then_deleted_and_presence_resolves(
# --- error mapping: 404 vs 422 ----------------------------------------------
async def test_schedule_and_override_for_unknown_child_are_404(
async def test_pattern_and_override_for_unknown_child_are_404(
admin_presence: SimpleNamespace,
) -> None:
"""A presence write naming a child that does not exist is 404."""
client = admin_presence.client
schedule = await client.put(
"/api/v1/admin/children/999/presence-schedule",
json=_schedule_body(),
pattern = await client.post(
_patterns_url(999),
json=_pattern_body(),
headers=_admin_headers(),
)
assert schedule.status_code == 404
assert schedule.json()["detail"] == "Child not found"
assert pattern.status_code == 404
assert pattern.json()["detail"] == "Child not found"
override = await client.post(
"/api/v1/admin/presence-overrides",
@@ -462,42 +874,70 @@ async def test_schedule_and_override_for_unknown_child_are_404(
@pytest.mark.parametrize(
"body",
("body", "field"),
[
_schedule_body(cycle_length_weeks=0), # below the 1..4 range
_schedule_body(cycle_length_weeks=5), # above the documented cap
(_pattern_body(cycle_length_weeks=0), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks=5), "cycle_length_weeks"),
# Strict JSON integers only: no float or string coercion.
(_pattern_body(cycle_length_weeks=2.5), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks="2"), "cycle_length_weeks"),
(_pattern_body(cycle_length_weeks=1.0), "cycle_length_weeks"),
# week 1 of the cycle missing:
_schedule_body(cycle_length_weeks=2, pattern={"0": [0]}),
{
"cycle_length_weeks": 1,
"anchor_date": "2026-13-40",
"pattern": {"0": [0]},
},
_schedule_body(pattern={"0": [7]}), # weekday out of 0..6
_schedule_body(pattern={"nope": [0]}), # week index not an integer
_schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list
(_pattern_body(cycle_length_weeks=2, pattern={"0": [0]}), "pattern"),
(_pattern_body(pattern={}), "pattern"),
(_pattern_body(anchor_date="2026-13-40"), "anchor_date"),
(_pattern_body(anchor_date="05/01/2026"), "anchor_date"),
(_pattern_body(pattern={"0": [7]}), "pattern"), # weekday out of 0..6
# Strict JSON integer weekdays: no bool, string or float coercion.
(_pattern_body(pattern={"0": [True]}), "pattern"),
(_pattern_body(pattern={"0": [False]}), "pattern"),
(_pattern_body(pattern={"0": ["1"]}), "pattern"),
(_pattern_body(pattern={"0": [1.0]}), "pattern"),
(_pattern_body(pattern={"nope": [0]}), "pattern"), # non-int week
(_pattern_body(pattern={"0": "0,2,4"}), "pattern"), # not a list
(_pattern_body(kind="sometimes"), "kind"),
(_pattern_body(kind="HOME"), "kind"),
(_pattern_body(name=""), "name"),
(_pattern_body(name=" "), "name"),
],
)
async def test_rejected_schedule_is_422(
admin_presence: SimpleNamespace, body: dict[str, object]
async def test_rejected_pattern_is_422(
admin_presence: SimpleNamespace, body: dict[str, object], field: str
) -> None:
"""A rejected schedule is 422 and stores nothing."""
"""A rejected pattern is 422 naming the field, and stores nothing."""
client = admin_presence.client
child_id = await _create_child(client)
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
response = await client.post(
_patterns_url(child_id),
json=body,
headers=_admin_headers(),
)
assert response.status_code == 422
assert response.status_code == 422, response.text
assert _detail_names(response, field), response.json()
assert (
await _dao_presence.PresenceSchedulesDao(
await _dao_presence.PresencePatternsDao(
admin_presence.database
).get_by_child(child_id)
is None
).list_by_child(child_id)
== []
)
@pytest.mark.parametrize("missing", ["name", "kind"])
async def test_pattern_missing_required_field_is_422(
admin_presence: SimpleNamespace, missing: str
) -> None:
"""The new required fields cannot be omitted (an old schedule body)."""
client = admin_presence.client
child_id = await _create_child(client)
body = _pattern_body()
del body[missing]
response = await client.post(
_patterns_url(child_id), json=body, headers=_admin_headers()
)
assert response.status_code == 422
assert _detail_names(response, missing)
@pytest.mark.parametrize(
"body",
[
@@ -548,7 +988,7 @@ async def test_overlapping_override_is_422_and_first_survives(
headers=_admin_headers(),
)
assert response.status_code == 422
_schedule, overrides, _engine = await _child_state(
_patterns, overrides, _engine = await _child_state(
admin_presence.database, child_id
)
assert [record.id for record in overrides] == [created.json()["id"]]
+9 -7
View File
@@ -124,18 +124,20 @@ async def _create_daily_definition(
return created.json()["id"]
async def _set_custody_schedule(client: object, child_id: int) -> None:
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
response = await client.put(
f"/api/v1/admin/children/{child_id}/presence-schedule",
async def _set_custody_pattern(client: object, child_id: int) -> None:
"""Add the two-week custody home pattern anchored on today (week 0)."""
response = await client.post(
f"/api/v1/admin/children/{child_id}/presence-patterns",
json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": _day(0),
"pattern": CUSTODY_PATTERN,
},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.status_code == 201
async def _materialize_horizon(database: object) -> None:
@@ -210,7 +212,7 @@ async def custody(temp_db_path: str) -> SimpleNamespace:
client, child_id, "Feed the fish", ["afternoon"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _set_custody_pattern(client, child_id)
await _materialize_horizon(database)
yield SimpleNamespace(
client=client,
@@ -290,7 +292,7 @@ async def test_skip_on_away_false_definition_contributes_nothing(
client, child_id, "Feed the fish", ["morning"],
skip_on_away=False,
)
await _set_custody_schedule(client, child_id)
await _set_custody_pattern(client, child_id)
await _materialize_horizon(database)
assert await _consequence(client, _body(child_id, 0, 14)) == 0
+96 -31
View File
@@ -1,14 +1,14 @@
"""Admin-plane presence READ route tests (task 07fde892).
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
``GET /api/v1/admin/children/{child_id}/presence-patterns`` and
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
httpx, through the shared stubbed-JWKS harness
(:mod:`tests.admin_jwt_harness`) — no network access.
- The schedule read answers ``{"schedule": {...}}`` in the PUT
response's shape for a seeded schedule, ``{"schedule": null}`` for a
child with none (present every day — NOT a 404), and 404 for an
unknown child.
- The patterns read answers ``{"patterns": [...]}`` in the POST
response's shape for seeded patterns, ordered by id (a child may have
several), ``{"patterns": []}`` for a child with none (present every
day — NOT a 404), and 404 for an unknown child.
- The overrides read answers ``{"overrides": [...]}`` in the POST
response's shape, ordered by start date, child id, id; ``child_id``
and the ``start``/``end`` range each narrow the list; a malformed or
@@ -44,8 +44,8 @@ FORBIDDEN_DETAIL = (
)
def _schedule_url(child_id: int) -> str:
return f"/api/v1/admin/children/{child_id}/presence-schedule"
def _patterns_url(child_id: int) -> str:
return f"/api/v1/admin/children/{child_id}/presence-patterns"
def _admin_headers() -> dict[str, str]:
@@ -99,20 +99,23 @@ async def reads(temp_db_path: str) -> SimpleNamespace:
@pytest.fixture
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
"""Two children with interleaved overrides (Ada has a schedule)."""
"""Two children with interleaved overrides (Ada has a home pattern)."""
client = reads.client
ada = await _create_child(client, "Ada")
ben = await _create_child(client, "Ben")
put = await client.put(
_schedule_url(ada),
created = await client.post(
_patterns_url(ada),
json={
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [4, 0, 2], "1": []},
},
headers=_admin_headers(),
)
assert put.status_code == 200
assert created.status_code == 201, created.text
ada_custody = created.json()
ada_march = await _create_override(
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
)
@@ -129,52 +132,114 @@ async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
ben_january=ben_january,
ben_march=ben_march,
ada_may=ada_may,
ada_custody=ada_custody,
)
# --- presence schedule read ------------------------------------------------
# --- presence patterns read ------------------------------------------------
async def test_schedule_read_returns_the_stored_schedule(
async def test_patterns_read_returns_the_stored_patterns(
seeded: SimpleNamespace,
) -> None:
"""A seeded schedule reads back in the PUT response's shape."""
"""A seeded pattern reads back in the POST response's shape."""
response = await seeded.client.get(
_schedule_url(seeded.ada), headers=_admin_headers()
_patterns_url(seeded.ada), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {
"schedule": {
"child_id": seeded.ada,
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [0, 2, 4], "1": []},
}
"patterns": [
{
"id": seeded.ada_custody["id"],
"child_id": seeded.ada,
"name": "Custody",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-01-05",
"pattern": {"0": [0, 2, 4], "1": []},
}
]
}
assert response.json()["patterns"] == [seeded.ada_custody]
async def test_schedule_read_without_schedule_is_null_not_404(
async def test_patterns_read_lists_several_patterns_in_id_order(
seeded: SimpleNamespace,
) -> None:
"""A child with no schedule (present every day) is schedule: null."""
response = await seeded.client.get(
_schedule_url(seeded.ben), headers=_admin_headers()
"""A child's patterns (zero or more) are listed ordered by id."""
client = seeded.client
away = await client.post(
_patterns_url(seeded.ada),
json={
"name": "Camp",
"kind": "away",
"cycle_length_weeks": 1,
"anchor_date": "2026-01-05",
"pattern": {"0": [6, 5]},
},
headers=_admin_headers(),
)
assert away.status_code == 201, away.text
third = await client.post(
_patterns_url(seeded.ada),
json={
"name": "Weekday lessons",
"kind": "home",
"cycle_length_weeks": 1,
"anchor_date": "2026-01-05",
"pattern": {"0": [1]},
},
headers=_admin_headers(),
)
assert third.status_code == 201, third.text
response = await client.get(
_patterns_url(seeded.ada), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {"schedule": None}
patterns = response.json()["patterns"]
assert patterns == [seeded.ada_custody, away.json(), third.json()]
ids = [pattern["id"] for pattern in patterns]
assert ids == sorted(ids)
assert patterns[1]["pattern"] == {"0": [5, 6]}
# Ben's list is untouched by Ada's patterns.
ben = await client.get(_patterns_url(seeded.ben), headers=_admin_headers())
assert ben.json() == {"patterns": []}
async def test_schedule_read_unknown_child_is_404(
async def test_patterns_read_without_patterns_is_empty_not_404(
seeded: SimpleNamespace,
) -> None:
"""A child with no pattern (present every day) is an empty list."""
response = await seeded.client.get(
_patterns_url(seeded.ben), headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {"patterns": []}
async def test_patterns_read_unknown_child_is_404(
reads: SimpleNamespace,
) -> None:
"""An unknown child is 404 with the children routes' detail."""
response = await reads.client.get(
_schedule_url(999), headers=_admin_headers()
_patterns_url(999), headers=_admin_headers()
)
assert response.status_code == 404
assert response.json() == {"detail": "Child not found"}
async def test_old_presence_schedule_read_route_is_gone(
seeded: SimpleNamespace,
) -> None:
"""The removed one-per-child schedule read no longer exists."""
response = await seeded.client.get(
f"/api/v1/admin/children/{seeded.ada}/presence-schedule",
headers=_admin_headers(),
)
assert response.status_code in (404, 405)
# --- presence overrides read -----------------------------------------------
@@ -302,12 +367,12 @@ async def test_overrides_read_unknown_child_is_404(
# --- authentication: the router's ONE require_admin --------------------------
@pytest.fixture(params=["schedule", "overrides"])
@pytest.fixture(params=["patterns", "overrides"])
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
"""Each read route's URL (the schedule route for a real child)."""
"""Each read route's URL (the patterns route for a real child)."""
if request.param == "overrides":
return OVERRIDES_URL
return _schedule_url(await _create_child(reads.client, "Ada"))
return _patterns_url(await _create_child(reads.client, "Ada"))
async def test_read_without_credential_is_401(
+5 -3
View File
@@ -141,7 +141,7 @@ async def _seed_household(
[bo.id],
[("morning", "17:00")],
)
# Today's instances first, while Ada has no presence schedule
# Today's instances first, while Ada has no presence pattern
# (she is present every day, so her instance IS generated).
await _materialize.materialize(
database,
@@ -183,12 +183,14 @@ async def _seed_household(
if record.due_time == "09:00"
)
# Ada's custody schedule, added AFTER materialization (the
# Ada's custody pattern (one home pattern), added AFTER materialization (the
# same order test_core_snapshot.py seeds): 2-week cycle
# anchored yesterday, empty week 0 covers today => absent
# today; an override returns her on today+2.
await _dao_presence.PresenceSchedulesDao(database).upsert_by_child(
await _dao_presence.PresencePatternsDao(database).create(
ada.id,
name="Custody",
kind="home",
cycle_length_weeks=2,
anchor_date=yesterday_iso,
pattern="|0,1,2,3,4,5,6",
+20 -5
View File
@@ -36,7 +36,7 @@ from custom_components.nestquest.core.snapshot import (
from custom_components.nestquest.core.dao_instances import QuestInstancesDao
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.materialize import materialize
@@ -116,14 +116,16 @@ async def test_instance_payload_shape_and_missed_omission() -> None:
async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None:
"""The builder produces the documented shape as literals, pinned in time.
Ada is AWAY today (a 2-week schedule whose absent week 0 covers today,
Ada is AWAY today (a 2-week home pattern whose empty week 0 covers today,
plus a presence override marking her present on today+2) so
``present`` is False and ``next_present`` is the override date. Her
open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``.
Bo's quest due at 17:00 is completed at 12:00 (on time) so its
payload ``state`` is ``completed`` and ``on_time`` is True. The first
scheduled active child is Ada, whose 2-week cycle anchored one day
before today puts ``cycle_day`` at 2.
patterned active child is Ada, whose FIRST (lowest id) pattern is a
2-week cycle anchored one day before today, putting ``cycle_day`` at
2; her second pattern (a 3-week one anchored today, covering no day)
must not drive the cycle day.
"""
# A standalone core database (the integration owns none): the
# builder and its seed run against it through the executor.
@@ -172,12 +174,25 @@ async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> Non
# covers today, so she is absent today; an override marks her present
# on today+2, so next_present resolves to that date and cycle_day is
# ((today - (today-1)) % 14) + 1 == 2.
await PresenceSchedulesDao(database).upsert_by_child(
patterns = PresencePatternsDao(database)
await patterns.create(
ada.id,
name="Home schedule",
kind="home",
cycle_length_weeks=2,
anchor_date=(today - datetime.timedelta(days=1)).isoformat(),
pattern="|0,1,2,3,4,5,6",
)
# A later pattern covering no day: it changes no presence answer and,
# not being the first pattern, must not set cycle_day (it would be 1).
await patterns.create(
ada.id,
name="Placeholder away",
kind="away",
cycle_length_weeks=3,
anchor_date=today_iso,
pattern="||",
)
returns_date = today + datetime.timedelta(days=2)
await PresenceOverridesDao(database).create(
ada.id,
+29 -18
View File
@@ -31,7 +31,7 @@ from custom_components.nestquest.core.dao_instances import (
)
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
PresenceSchedulesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.dao_rules import (
ScheduleRulesDao,
@@ -72,7 +72,7 @@ ALL_TABLES = (
"schedule_rules",
"quest_definitions",
"quest_definition_assignees",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -88,7 +88,7 @@ class World:
self.admins = AdminUsersDao(database)
self.rules = ScheduleRulesDao(database)
self.definitions = QuestDefinitionsDao(database)
self.schedules = PresenceSchedulesDao(database)
self.patterns = PresencePatternsDao(database)
self.overrides = PresenceOverridesDao(database)
self.instances = QuestInstancesDao(database)
self.events = CompletionEventsDao(database)
@@ -105,8 +105,8 @@ class World:
_stamp(),
assignee_child_ids=[self.child.id],
)
self.schedule = await self.schedules.upsert_by_child(
self.child.id, 2, D1, "0,2,4|1,3"
self.pattern = await self.patterns.create(
self.child.id, "Home schedule", "home", 2, D1, "0,2,4|1,3"
)
self.override = await self.overrides.create(
self.child.id, D1, D2, True, note="test"
@@ -244,28 +244,39 @@ def test_matrix_quest_definitions_crud_and_constraints(tmp_path) -> None:
assert _with_world(tmp_path / "m-definitions.db")(_body) is True
def test_matrix_presence_schedules_crud_and_constraints(tmp_path) -> None:
def test_matrix_presence_patterns_crud_and_constraints(tmp_path) -> None:
async def _body(w: World):
# insert (seeded) + read
fetched = await w.schedules.get_by_child(w.child.id)
fetched = await w.patterns.get(w.pattern.id)
assert fetched == w.pattern
assert fetched.pattern == "0,2,4|1,3"
# update path: upsert replaces in place (same row id)
replaced = await w.schedules.upsert_by_child(
w.child.id, 2, D2, "1,3|0,2,4"
# a second pattern for the same child is allowed (schema 9)
away = await w.patterns.create(
w.child.id, "Weekend away", "away", 1, D1, "5,6"
)
assert [r.id for r in await w.patterns.list_by_child(w.child.id)] == [
w.pattern.id,
away.id,
]
# update path: rewrites in place (same row id)
replaced = await w.patterns.update(
w.pattern.id, "Home schedule", "home", 2, D2, "1,3|0,2,4"
)
assert replaced.id == fetched.id
assert (await w.schedules.get_by_child(w.child.id)).pattern == (
"1,3|0,2,4"
)
assert (await w.patterns.get(w.pattern.id)).pattern == "1,3|0,2,4"
# constraint: malformed pattern CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.schedules.upsert_by_child(w.child.id, 2, D1, "0,2")
# delete: row gone, child present every day
assert await w.schedules.delete(w.child.id) is True
assert await w.schedules.get_by_child(w.child.id) is None
await w.patterns.create(w.child.id, "Bad", "home", 2, D1, "0,2")
# constraint: kind CHECK
with pytest.raises(sqlite3.IntegrityError):
await w.patterns.create(w.child.id, "Bad", "maybe", 1, D1, "0")
# delete: rows gone, child present every day
assert await w.patterns.delete(w.pattern.id) is True
assert await w.patterns.delete(away.id) is True
assert await w.patterns.list_by_child(w.child.id) == []
return True
assert _with_world(tmp_path / "m-schedules.db")(_body) is True
assert _with_world(tmp_path / "m-patterns.db")(_body) is True
def test_matrix_presence_overrides_crud_and_constraints(tmp_path) -> None:
+313 -148
View File
@@ -1,4 +1,4 @@
"""Tests for dao_presence.py: presence schedules and overrides DAO."""
"""Tests for dao_presence.py: presence patterns and overrides DAO."""
from __future__ import annotations
import asyncio
@@ -10,8 +10,8 @@ from custom_components.nestquest.core.dao_children import ChildrenDao
from custom_components.nestquest.core.dao_presence import (
PresenceOverrideRecord,
PresenceOverridesDao,
PresenceScheduleRecord,
PresenceSchedulesDao,
PresencePatternRecord,
PresencePatternsDao,
)
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.migrations import apply_migrations
@@ -36,22 +36,22 @@ async def _prepare(path) -> tuple:
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
await database.open(path)
await apply_migrations(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database)
children = ChildrenDao(database)
child = await children.create("Ada", NOW)
return database, schedules, overrides, children, child
return database, patterns, overrides, children, child
def _with_db(tmp_path, name):
def _run_test(body):
async def _main():
database, schedules, overrides, children, child = await _prepare(
database, patterns, overrides, children, child = await _prepare(
tmp_path / name
)
try:
return await body(
database, schedules, overrides, children, child
database, patterns, overrides, children, child
)
finally:
await database.close()
@@ -62,105 +62,152 @@ def _with_db(tmp_path, name):
# ---------------------------------------------------------------------------
# presence_schedules: upsert by child
# presence_patterns: create / list by child
# ---------------------------------------------------------------------------
def test_upsert_creates_then_updates_same_row(tmp_path) -> None:
"""Second upsert for the same child UPDATES, not raises."""
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2,4|1,3"
)
assert isinstance(first, PresenceScheduleRecord)
assert first.child_id == child.id
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2,4"
)
assert second.id == first.id, "upsert must reuse the existing row"
fetched = await schedules.get_by_child(child.id)
assert fetched.anchor_date == "2026-09-14"
assert fetched.pattern == "1,3|0,2,4"
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
)
assert rows == (1,)
return second
_with_db(tmp_path, "upsert.db")(_body)
def test_upsert_typed_record_fields(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
record = await schedules.upsert_by_child(
child.id, 4, "2026-09-07", "0|1|2|3"
def test_create_returns_typed_record_fields(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Mum's rota", "home", 4, "2026-09-07", "0|1|2|3"
)
assert isinstance(record, PresencePatternRecord)
assert record.id == 1
assert record.child_id == child.id
assert record.name == "Mum's rota"
assert record.kind == "home"
assert record.cycle_length_weeks == 4
assert record.anchor_date == "2026-09-07"
assert record.pattern == "0|1|2|3"
assert await patterns.get(record.id) == record
return record
_with_db(tmp_path, "upsert-fields.db")(_body)
_with_db(tmp_path, "create-fields.db")(_body)
def test_upsert_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.upsert_by_child(
999, 2, "2026-09-07", "0,2|1,3"
def test_create_many_patterns_for_one_child(tmp_path) -> None:
"""Schema 9 drops the one-per-child UNIQUE: a second create for the
same child adds a row instead of raising or replacing."""
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
second = await patterns.create(
child.id, "Thu/Fri away", "away", 1, "2026-09-07", "3,4"
)
third = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
assert len({first.id, second.id, third.id}) == 3
listed = await patterns.list_by_child(child.id)
assert listed == [first, second, third]
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (3,)
return listed
_with_db(tmp_path, "create-many.db")(_body)
def test_create_unknown_child_raises_value_error(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.create(
999, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
return None
_with_db(tmp_path, "upsert-bad-child.db")(_body)
_with_db(tmp_path, "create-bad-child.db")(_body)
def test_upsert_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await schedules.upsert_by_child(child.id, 2, bad, "0|1")
await patterns.create(child.id, "Home", "home", 2, bad, "0|1")
assert await patterns.list_by_child(child.id) == []
return None
_with_db(tmp_path, "upsert-bad-date.db")(_body)
_with_db(tmp_path, "create-bad-date.db")(_body)
def test_upsert_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
def test_create_malformed_pattern_rejected_by_schema(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2"
)
return None
_with_db(tmp_path, "upsert-bad-pattern.db")(_body)
_with_db(tmp_path, "create-bad-pattern.db")(_body)
def test_upsert_two_children_have_distinct_rows(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
@pytest.mark.parametrize("bad_kind", ["present", "Home", "AWAY", ""])
def test_create_unknown_kind_rejected_by_schema(tmp_path, bad_kind) -> None:
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.create(
child.id, "Home", bad_kind, 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return None
_with_db(tmp_path, "create-bad-kind.db")(_body)
def test_list_by_child_two_children_are_isolated(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW)
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
await schedules.upsert_by_child(other.id, 2, "2026-09-07", "1,3|0,2")
mine = await schedules.get_by_child(child.id)
theirs = await schedules.get_by_child(other.id)
assert mine.id != theirs.id
assert mine.pattern == "0,2|1,3"
assert theirs.pattern == "1,3|0,2"
await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.create(
other.id, "Home", "home", 2, "2026-09-07", "1,3|0,2"
)
mine = await patterns.list_by_child(child.id)
theirs = await patterns.list_by_child(other.id)
assert [r.child_id for r in mine] == [child.id]
assert [r.child_id for r in theirs] == [other.id]
assert mine[0].id != theirs[0].id
assert mine[0].pattern == "0,2|1,3"
assert theirs[0].pattern == "1,3|0,2"
return mine
_with_db(tmp_path, "upsert-two-children.db")(_body)
_with_db(tmp_path, "list-two-children.db")(_body)
def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
"""Racing upserts for one child both succeed; exactly one row.
def test_list_by_child_orders_by_id(tmp_path) -> None:
"""Ordering is by id (creation order), not by name, kind or anchor."""
async def _body(database, patterns, overrides, children, child):
created = [
await patterns.create(
child.id, "Zed", "away", 1, "2026-09-21", "6"
),
await patterns.create(
child.id, "Alpha", "home", 1, "2026-09-07", "0"
),
await patterns.create(
child.id, "Mid", "away", 2, "2026-09-14", "1|2"
),
]
listed = await patterns.list_by_child(child.id)
assert [r.id for r in listed] == sorted(r.id for r in created)
assert [r.name for r in listed] == ["Zed", "Alpha", "Mid"]
return listed
_with_db(tmp_path, "list-order.db")(_body)
def test_create_concurrent_same_child_both_land(tmp_path) -> None:
"""Racing creates for one child both succeed as DISTINCT rows.
Deterministic interleaving: a controllable executor gates task A's
job AFTER it has acquired the connection lock and begun its
transaction, so task B is created while A provably owns the
transaction and must queue on the lock. Both upserts land, and
the table holds exactly one row (the ON CONFLICT upsert, not two
plain INSERTs, is what avoids a UNIQUE violation).
transaction and must queue on the lock. With no UNIQUE on
``child_id`` (schema 9) both INSERTs land and the child lists two
patterns in id order.
"""
async def _main():
from unittest.mock import MagicMock
@@ -180,13 +227,6 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
a_started = asyncio.Event()
async def _gated_executor(fn, *args):
# Gate the SECOND gated-window job: the first is the
# transaction's BEGIN (submitted and awaited by
# transaction() before upsert_by_child's child-SELECT
# runs). Pausing the child-existence SELECT proves the
# BEGIN has EXECUTED — SQLite is actually inside the open
# transaction, with A holding the connection lock — when
# task B gets scheduled and must queue.
if armed["active"]:
name = getattr(fn, "__name__", "")
if name == "_fetch_one" and not armed["gated"]:
@@ -197,17 +237,17 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
hass.async_add_executor_job = _gated_executor
database = NestQuestDatabase(hass.async_add_executor_job)
await database.open(tmp_path / "upsert-race.db")
await database.open(tmp_path / "create-race.db")
try:
await apply_migrations(database)
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
child = await children.create("Ada", NOW)
armed["active"] = True
task_a = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
patterns.create(
child.id, "A", "home", 2, "2026-09-07", "0,2|1,3"
)
)
# Wait until task A's child-existence SELECT (inside its
@@ -217,81 +257,197 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
await a_started.wait()
armed["active"] = False
task_b = asyncio.ensure_future(
schedules.upsert_by_child(
child.id, 2, "2026-09-14", "1,3|0,2"
patterns.create(
child.id, "B", "away", 1, "2026-09-14", "5,6"
)
)
await asyncio.sleep(0)
# Release A; B's upsert must wait for A's commit and then
# land as the ON CONFLICT update path.
# Release A; B must wait for A's commit and then insert its
# own row.
gate_open.set()
record_a, record_b = await asyncio.gather(task_a, task_b)
assert record_a.id == record_b.id
fetched = await schedules.get_by_child(child.id)
assert fetched is not None
assert record_a.id != record_b.id
assert (record_a.name, record_b.name) == ("A", "B")
listed = await patterns.list_by_child(child.id)
rows = await database.fetch_one(
"SELECT COUNT(*) FROM presence_schedules"
"SELECT COUNT(*) FROM presence_patterns"
)
assert rows == (1,)
return fetched
assert rows == (2,)
return record_a, record_b, listed
finally:
await database.close()
fetched = _run(_main())
assert fetched.pattern in {"0,2|1,3", "1,3|0,2"}
record_a, record_b, listed = _run(_main())
assert listed == [record_a, record_b]
# ---------------------------------------------------------------------------
# presence_schedules: get / delete
# presence_patterns: list / get / update / delete
# ---------------------------------------------------------------------------
def test_get_by_child_missing_schedule_returns_none(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.get_by_child(child.id)
def test_list_by_child_without_patterns_returns_empty(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.list_by_child(child.id)
assert _with_db(tmp_path, "list-none.db")(_body) == []
def test_list_by_child_unknown_child_raises(tmp_path) -> None:
"""[] must mean ONLY 'no pattern rows': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so list raises instead.
"""
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="child 999 does not exist"):
await patterns.list_by_child(999)
return None
_with_db(tmp_path, "list-unknown.db")(_body)
def test_get_missing_pattern_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.get(999)
assert _with_db(tmp_path, "get-none.db")(_body) is None
def test_get_by_child_unknown_child_raises(tmp_path) -> None:
"""None must mean ONLY 'no schedule row': an unknown child id is a
caller bug, and silently reading a nonexistent child as 'present
every day' would be wrong — so get raises instead.
"""
async def _body(database, schedules, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await schedules.get_by_child(999)
def test_update_rewrites_every_field_but_child(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home weeks", "home", 2, "2026-09-07", "0,2,4|1,3"
)
updated = await patterns.update(
record.id, "Weekends away", "away", 1, "2026-09-12", "5,6"
)
assert updated == PresencePatternRecord(
id=record.id,
child_id=child.id,
name="Weekends away",
kind="away",
cycle_length_weeks=1,
anchor_date="2026-09-12",
pattern="5,6",
)
assert await patterns.get(record.id) == updated
assert await patterns.list_by_child(child.id) == [updated]
return updated
_with_db(tmp_path, "update.db")(_body)
def test_update_touches_only_the_addressed_pattern(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
)
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
updated = await patterns.update(
second.id, "Away", "away", 1, "2026-09-07", "5,6"
)
assert await patterns.list_by_child(child.id) == [first, updated]
return updated
_with_db(tmp_path, "update-one.db")(_body)
def test_update_unknown_id_returns_none(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
result = await patterns.update(
999, "Home", "home", 1, "2026-09-07", "0"
)
assert await patterns.list_by_child(child.id) == []
return result
assert _with_db(tmp_path, "update-unknown.db")(_body) is None
def test_update_malformed_anchor_date_raises(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0"
)
for bad in ("not-a-date", "2026-9-7", "2026-13-01", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await patterns.update(record.id, "Home", "home", 1, bad, "0")
assert await patterns.get(record.id) == record
return None
_with_db(tmp_path, "get-unknown.db")(_body)
_with_db(tmp_path, "update-bad-date.db")(_body)
def test_update_bad_kind_and_pattern_rejected_by_schema(tmp_path) -> None:
"""The CHECKs apply on UPDATE exactly as on INSERT, and a rejected
update leaves the stored row unchanged."""
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "present", 2, "2026-09-07", "0,2|1,3"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 2, "2026-09-07", "0,2"
)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await patterns.update(
record.id, "Home", "home", 5, "2026-09-07", "0|1|2|3|4"
)
assert await patterns.get(record.id) == record
return None
_with_db(tmp_path, "update-bad-check.db")(_body)
def test_delete_removes_and_returns_true_then_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
assert await schedules.delete(child.id) is True
assert await schedules.get_by_child(child.id) is None
assert await schedules.delete(child.id) is False
async def _body(database, patterns, overrides, children, child):
record = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert await patterns.delete(record.id) is True
assert await patterns.get(record.id) is None
assert await patterns.list_by_child(child.id) == []
assert await patterns.delete(record.id) is False
return None
_with_db(tmp_path, "delete.db")(_body)
def test_delete_child_without_schedule_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
return await schedules.delete(child.id)
def test_delete_missing_pattern_returns_false(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
return await patterns.delete(999)
assert _with_db(tmp_path, "delete-none.db")(_body) is False
def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
first = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
def test_delete_leaves_the_childs_other_patterns(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 1, "2026-09-07", "0,1,2"
)
await schedules.delete(child.id)
second = await schedules.upsert_by_child(
child.id, 2, "2026-09-07", "0,2|1,3"
second = await patterns.create(
child.id, "Away", "away", 1, "2026-09-07", "5"
)
assert await patterns.delete(first.id) is True
assert await patterns.list_by_child(child.id) == [second]
return None
_with_db(tmp_path, "delete-others.db")(_body)
def test_delete_then_create_makes_fresh_row(tmp_path) -> None:
async def _body(database, patterns, overrides, children, child):
first = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
await patterns.delete(first.id)
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert second.id != first.id
return second
@@ -305,7 +461,7 @@ def test_delete_then_upsert_creates_fresh_row(tmp_path) -> None:
def test_override_create_returns_typed_record(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-12-21", "2027-01-04", False,
note="Holiday abroad",
@@ -322,7 +478,7 @@ def test_override_create_returns_typed_record(tmp_path) -> None:
def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-14", "2026-09-14", True
)
@@ -335,7 +491,7 @@ def test_override_create_single_day_same_start_and_end(tmp_path) -> None:
def test_override_create_unknown_child_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="does not exist"):
await overrides.create(
999, "2026-09-14", "2026-09-14", True
@@ -346,7 +502,7 @@ def test_override_create_unknown_child_raises(tmp_path) -> None:
def test_override_create_malformed_dates_raise(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
for bad in ("not-a-date", "2026-9-14", ""):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.create(child.id, bad, "2026-09-14", True)
@@ -358,7 +514,7 @@ def test_override_create_malformed_dates_raise(tmp_path) -> None:
def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
await overrides.create(
child.id, "2026-09-14", "2026-09-13", True
@@ -371,7 +527,7 @@ def test_override_end_before_start_rejected_by_schema(tmp_path) -> None:
def test_override_list_by_child_and_range_filters_correctly(
tmp_path,
) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
await overrides.create(
child.id, "2026-09-01", "2026-09-05", False, note="before"
)
@@ -400,7 +556,7 @@ def test_override_list_by_child_and_range_filters_correctly(
def test_override_list_orders_by_start_date(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
late = await overrides.create(
child.id, "2026-09-20", "2026-09-21", True
)
@@ -417,7 +573,7 @@ def test_override_list_orders_by_start_date(tmp_path) -> None:
def test_override_list_per_child_isolation(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
other = await children.create("Bo", NOW)
await overrides.create(child.id, "2026-09-10", "2026-09-11", True)
await overrides.create(other.id, "2026-09-10", "2026-09-11", False)
@@ -432,7 +588,7 @@ def test_override_list_per_child_isolation(tmp_path) -> None:
def test_override_list_malformed_range_raises(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
await overrides.list_by_child_and_range(
child.id, "oops", "2026-09-30"
@@ -447,7 +603,7 @@ def test_override_list_malformed_range_raises(tmp_path) -> None:
def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.list_by_child_and_range(
999, "2026-09-01", "2026-09-30"
)
@@ -456,7 +612,7 @@ def test_override_list_unknown_child_returns_empty(tmp_path) -> None:
def test_override_get_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True, note="camp"
)
@@ -470,7 +626,7 @@ def test_override_get_round_trip(tmp_path) -> None:
def test_override_delete_round_trip(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
record = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
@@ -486,14 +642,14 @@ def test_override_delete_round_trip(tmp_path) -> None:
def test_override_delete_missing_returns_false(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
return await overrides.delete(999)
assert _with_db(tmp_path, "override-delete-missing.db")(_body) is False
def test_override_delete_leaves_other_overrides(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
first = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
@@ -510,18 +666,25 @@ def test_override_delete_leaves_other_overrides(tmp_path) -> None:
_with_db(tmp_path, "override-delete-others.db")(_body)
def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting the schedule does not touch overrides and vice versa."""
async def _body(database, schedules, overrides, children, child):
await schedules.upsert_by_child(child.id, 2, "2026-09-07", "0,2|1,3")
def test_pattern_and_override_independent_for_same_child(tmp_path) -> None:
"""Deleting a pattern does not touch overrides and vice versa."""
async def _body(database, patterns, overrides, children, child):
pattern = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
override = await overrides.create(
child.id, "2026-09-10", "2026-09-11", True
)
assert await schedules.delete(child.id) is True
assert await patterns.delete(pattern.id) is True
found = await overrides.list_by_child_and_range(
child.id, "2026-09-01", "2026-09-30"
)
assert [r.id for r in found] == [override.id]
second = await patterns.create(
child.id, "Home", "home", 2, "2026-09-07", "0,2|1,3"
)
assert await overrides.delete(override.id) is True
assert await patterns.list_by_child(child.id) == [second]
return found
_with_db(tmp_path, "independent.db")(_body)
@@ -533,7 +696,7 @@ def test_schedule_and_override_independent_for_same_child(tmp_path) -> None:
def test_presence_sql_lives_only_in_dao_module() -> None:
"""Guardrail: presence_schedules/presence_overrides SQL may appear
"""Guardrail: presence_patterns/presence_overrides SQL may appear
only in the DAO module (and the schema DDL declarations).
RECURSIVE scan of package and tests, FROM/INTO/UPDATE/DELETE FROM/
JOIN pattern, with exact repo-relative exclusions justified by
@@ -562,7 +725,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
}
sql_pattern = re.compile(
r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_schedules|presence_overrides)\b",
r"(presence_patterns|presence_overrides)\b",
re.IGNORECASE,
)
offenders: list[str] = []
@@ -574,7 +737,7 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
if sql_pattern.search(py.read_text()):
offenders.append(relative)
assert offenders == [], (
f"SQL touching presence_schedules/presence_overrides leaked "
f"SQL touching presence_patterns/presence_overrides leaked "
f"into: {offenders}"
)
@@ -582,8 +745,9 @@ def test_presence_sql_lives_only_in_dao_module() -> None:
def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
"""This test module must exercise the DAO, not raw SQL, for these
tables. Exemptions are EXACT lines, not whole-function spans: the
two single-statement COUNT(*) row probes (the only honest way to
prove 'exactly one row exists' without a listing method) and the
single-statement COUNT(*) row probes (the only honest way to prove
how many rows the whole table holds, which a per-child listing
cannot) and the
guard functions' own regex spans. Any other raw SQL added anywhere
in this file fails the guard.
"""
@@ -615,7 +779,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
isinstance(sub, ast.Constant)
and isinstance(sub.value, str)
and sub.value.strip()
== "SELECT COUNT(*) FROM presence_schedules"
== "SELECT COUNT(*) FROM presence_patterns"
and sub.lineno is not None
):
excluded.add(sub.lineno)
@@ -627,7 +791,8 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
)
sql_pattern = re.compile(
r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|"
r"FROM|JOIN)\s+[`'\"]*(\[)?(presence_schedules|presence_overrides)"
r"FROM|JOIN)\s+[`'\"]*(\[)?"
r"(presence_patterns|presence_overrides)"
r"\b",
re.IGNORECASE,
)
@@ -641,7 +806,7 @@ def test_dao_presence_test_file_uses_dao_not_raw_table_sql() -> None:
def test_override_create_round_trip_single_and_range(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
single = await overrides.create(
child.id, "2026-07-20", "2026-07-20", False
)
@@ -662,7 +827,7 @@ def test_override_create_round_trip_single_and_range(tmp_path) -> None:
def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
existing = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False, note="holiday"
)
@@ -698,7 +863,7 @@ def test_override_overlap_rejected_and_names_the_conflict(tmp_path) -> None:
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):
async def _body(database, patterns, 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(
@@ -715,7 +880,7 @@ def test_override_overlap_is_per_child(tmp_path) -> None:
def test_override_remove_then_recreate_overlapping(tmp_path) -> None:
async def _body(database, schedules, overrides, children, child):
async def _body(database, patterns, overrides, children, child):
first = await overrides.create(
child.id, "2026-07-20", "2026-07-24", False
)
+162 -17
View File
@@ -12,7 +12,7 @@ from custom_components.nestquest.core.dao_instances import (
CompletionEventsDao,
QuestInstancesDao,
)
from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao
from custom_components.nestquest.core.dao_presence import PresencePatternsDao
from custom_components.nestquest.core.dao_rules import (
QuestDefinitionsDao,
schedule_rule_to_storage,
@@ -28,6 +28,10 @@ from custom_components.nestquest.core.quest_definitions import (
create_quest_definition,
edit_quest_definition,
)
from custom_components.nestquest.core.presence_management import (
create_presence_pattern,
delete_presence_pattern,
)
from custom_components.nestquest.core.recurrence import (
RuleType,
ScheduleRule,
@@ -83,7 +87,7 @@ def _build_expected(start, def1_id, def2_id, a_id, b_id, c_id) -> set:
due_time) tuples over the four-week window.
Custody: A is present week 0 and 2 of the two-week cycle, absent
1 and 3; B is the mirror; C has no schedule (present every day).
1 and 3; B is the mirror; C has no pattern (present every day).
"""
expected: set = set()
start_dt = datetime.date.fromisoformat(start)
@@ -128,21 +132,21 @@ async def _collect_instances(database, child_ids, start, end) -> set:
def test_materialize_two_custody_one_always_present(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present
c = await children.create("Cleo", NOW) # no pattern: always present
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=27)).isoformat()
await schedules.upsert_by_child(
a.id, 2, start_iso, "0,1,2,3,4,5,6|"
await patterns.create(
a.id, "Home schedule", "home", 2, start_iso, "0,1,2,3,4,5,6|"
)
await schedules.upsert_by_child(
b.id, 2, start_iso, "|0,1,2,3,4,5,6"
await patterns.create(
b.id, "Home schedule", "home", 2, start_iso, "|0,1,2,3,4,5,6"
)
def1 = await create_quest_definition(
@@ -181,6 +185,136 @@ def test_materialize_two_custody_one_always_present(tmp_path) -> None:
_with_db(tmp_path, "materialize.db")(_body)
async def _due_dates(database, child_id, start_iso, end_iso) -> set[str]:
records = await QuestInstancesDao(database).list_by_date_range(
child_id, start_iso, end_iso
)
return {record.due_date for record in records}
def test_materialize_home_and_away_patterns_combine(tmp_path) -> None:
"""Several patterns per child combine: a covering ``away`` beats a
covering ``home``. An every-day home pattern plus an away Thu+Fri
pattern leaves the child absent exactly on Thursday and Friday, so a
skip_on_away definition skips those days while a non-skipping one
still generates them."""
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
patterns = PresencePatternsDao(database)
await patterns.create(
child.id, "Home schedule", "home", 1, start_iso, "0,1,2,3,4,5,6"
)
await patterns.create(
child.id, "Thu-Fri away", "away", 1, start_iso, "3,4"
)
skipping = await create_quest_definition(
database,
"Skipping chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
skip_on_away=True,
)
keeping = await create_quest_definition(
database,
"Keeping chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["evening"],
skip_on_away=False,
)
count = await materialize(database, start_iso, end_iso)
assert count == 5 + 7
records = await QuestInstancesDao(database).list_by_date_range(
child.id, start_iso, end_iso
)
by_definition: dict[int, set[str]] = {}
for record in records:
by_definition.setdefault(record.definition_id, set()).add(
record.due_date
)
thursday_friday = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in (3, 4)
}
all_week = {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in range(7)
}
assert by_definition[skipping.definition.id] == (
all_week - thursday_friday
)
assert by_definition[keeping.definition.id] == all_week
_with_db(tmp_path, "materialize-multi-pattern.db")(_body)
def test_presence_pattern_create_and_delete_regenerate_instances(
tmp_path,
) -> None:
"""Creating and deleting patterns re-materializes the child's future.
Home Mon..Wed plus away Tuesday leaves Monday and Wednesday; deleting
the away pattern restores Tuesday; deleting the last (home) pattern
makes the child present every day again.
"""
async def _body(database):
child = await ChildrenDao(database).create("Ada", NOW)
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
await create_quest_definition(
database,
"Daily chore",
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
[child.id],
["morning"],
)
window = {"today": start, "horizon_days": 6}
def days(*offsets: int) -> set[str]:
return {
(start + datetime.timedelta(days=offset)).isoformat()
for offset in offsets
}
home = await create_presence_pattern(
database, child.id, "Home schedule", "home", 1, start_iso,
{0: {0, 1, 2}}, **window,
)
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 1, 2)
)
away = await create_presence_pattern(
database, child.id, "Tuesday away", "away", 1, start_iso,
{0: {1}}, **window,
)
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 2)
)
deleted = await delete_presence_pattern(database, away.id, **window)
assert deleted == away
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(0, 1, 2)
)
await delete_presence_pattern(database, home.id, **window)
assert await PresencePatternsDao(database).list_by_child(
child.id
) == []
assert await _due_dates(database, child.id, start_iso, end_iso) == (
days(*range(7))
)
_with_db(tmp_path, "materialize-pattern-delete.db")(_body)
def test_materialize_shares_one_generated_at_per_run(tmp_path) -> None:
async def _body(database):
children = ChildrenDao(database)
@@ -638,7 +772,7 @@ def test_materialize_honours_overrides(tmp_path) -> None:
start = _future_monday()
start_iso = start.isoformat()
end_iso = (start + datetime.timedelta(days=6)).isoformat()
# No schedule means the child is present every day; a blocking
# No presence pattern means the child is present every day; a blocking
# override covering the whole week must still suppress every day.
await overrides.create(
child.id, start_iso, end_iso, False, note="away all week"
@@ -1623,27 +1757,38 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None:
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
)
from custom_components.nestquest.core.presence import PresenceSchedule
from custom_components.nestquest.core.presence import PresencePattern
children = ChildrenDao(database)
schedules = PresenceSchedulesDao(database)
patterns = PresencePatternsDao(database)
overrides = PresenceOverridesDao(database)
a = await children.create("Ada", NOW) # present weeks 0, 2
b = await children.create("Bo", NOW) # present weeks 1, 3
c = await children.create("Cleo", NOW) # no schedule: always present
c = await children.create("Cleo", NOW) # no pattern: always present
anchor = datetime.date.fromisoformat(_E2E_ANCHOR)
start_iso = anchor.isoformat()
end_iso = (anchor + datetime.timedelta(days=27)).isoformat()
# Two-week custody with OPPOSITE weeks, encoded through the
# PresenceSchedule model on the fixed Monday anchor.
# PresencePattern model (one ``home`` pattern each) on the fixed
# Monday anchor.
all_week = frozenset(range(7))
a_schedule = PresenceSchedule(a.id, 2, anchor, {0: all_week, 1: frozenset()})
b_schedule = PresenceSchedule(b.id, 2, anchor, {0: frozenset(), 1: all_week})
await schedules.upsert_by_child(a.id, 2, start_iso, a_schedule.encode())
await schedules.upsert_by_child(b.id, 2, start_iso, b_schedule.encode())
a_home = PresencePattern(
a.id, "Home schedule", "home", 2, anchor,
{0: all_week, 1: frozenset()},
)
b_home = PresencePattern(
b.id, "Home schedule", "home", 2, anchor,
{0: frozenset(), 1: all_week},
)
await patterns.create(
a.id, a_home.name, a_home.kind, 2, start_iso, a_home.encode()
)
await patterns.create(
b.id, b_home.name, b_home.kind, 2, start_iso, b_home.encode()
)
def_ids = {}
def_ids["daily"] = (await create_quest_definition(
+336 -9
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import datetime
import hashlib
import logging
import sqlite3
@@ -12,6 +13,9 @@ import pytest
from custom_components.nestquest.core.db import NestQuestDatabase
from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL
from custom_components.nestquest.core.migrations import (
MIGRATED_PRESENCE_PATTERN_NAME,
MIGRATION_1_V1_DDL,
MIGRATION_9_PRESENCE_PATTERNS,
MIGRATIONS,
VERSION_TABLE,
VERSION_TABLE_DDL,
@@ -69,7 +73,7 @@ def _counts(database) -> dict[str, int]:
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -107,7 +111,7 @@ def test_fresh_file_migration_creates_all_v1_tables(tmp_path) -> None:
"quest_definitions",
"quest_definition_assignees",
"quest_definition_windows",
"presence_schedules",
"presence_patterns",
"presence_overrides",
"quest_instances",
"completion_events",
@@ -154,10 +158,26 @@ def test_migration_list_shape() -> None:
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key, migration 6 adds actor_child_id to completion_events,
migration 7 adds the meta_state table and migration 8 adds
skip_on_away to quest_definitions.
migration 7 adds the meta_state table, migration 8 adds
skip_on_away to quest_definitions and migration 9 retires
presence_schedules for presence_patterns.
Migration 1 is the v1 DDL as it shipped: the canonical statements
except that it still creates the retired presence_schedules table
where the canonical list now creates presence_patterns.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert MIGRATIONS[0] is MIGRATION_1_V1_DDL
assert len(MIGRATION_1_V1_DDL) == len(SCHEMA_V1_STATEMENTS)
differing = [
(shipped, canonical)
for shipped, canonical in zip(MIGRATION_1_V1_DDL, SCHEMA_V1_STATEMENTS)
if shipped != canonical
]
assert len(differing) == 1
shipped, canonical = differing[0]
assert "CREATE TABLE IF NOT EXISTS presence_schedules" in shipped
assert "CREATE TABLE IF NOT EXISTS presence_patterns" in canonical
assert not any("presence_patterns" in sql for sql in MIGRATION_1_V1_DDL)
assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
@@ -165,7 +185,8 @@ def test_migration_list_shape() -> None:
assert callable(MIGRATIONS[5])
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
assert callable(MIGRATIONS[7])
assert len(MIGRATIONS) == 8
assert MIGRATIONS[8] is MIGRATION_9_PRESENCE_PATTERNS
assert len(MIGRATIONS) == 9
# ---------------------------------------------------------------------------
@@ -474,7 +495,8 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"""
# Freeze the version-2 shape: definitions carry child_id, the
# instance table has no window column, and the assignees/windows
# tables do not exist yet — everything else matches the current DDL.
# tables do not exist yet — everything else matches the shipped v1
# DDL (migration 1, which still creates presence_schedules).
_v2_replaced = (
"quest_definitions ",
"quest_definition_assignees ",
@@ -482,7 +504,7 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"quest_instances ",
"completion_events ",
)
for sql in SCHEMA_V1_STATEMENTS:
for sql in MIGRATION_1_V1_DDL:
if any(name in sql for name in _v2_replaced):
continue
_run(database.execute(sql))
@@ -694,7 +716,7 @@ def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
before = _version(database)
assert before == 7
final = _migrate(database)
final = _migrate(database, MIGRATIONS[:8])
assert final == before + 1
assert _version(database) == before + 1
@@ -728,6 +750,311 @@ def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
_run(database.close())
# ---------------------------------------------------------------------------
# Migration 9: presence_schedules -> presence_patterns
# ---------------------------------------------------------------------------
#: Pre-migration presence_schedules rows: (id, child_id, cycle, anchor,
#: pattern). Covers a 1-, 2- and 4-week cycle, an anchor that is not a
#: Monday, and the all-absent empty pattern; child 4 has no schedule.
_V8_SCHEDULES = (
(1, 1, 2, "2026-01-05", "0,1,2,3,4|5,6"),
(2, 2, 1, "2025-12-31", "0,2,4"),
(5, 3, 4, "2024-02-26", "0|1,2|3,4,5|6"),
(7, 5, 1, "2026-03-01", ""),
)
#: Pre-migration overrides: they must keep beating the migrated patterns.
_V8_OVERRIDES = (
(1, "2026-02-02", "2026-02-08", 1),
(4, "2026-02-10", "2026-02-11", 0),
)
def _seed_v8_database(database) -> None:
"""Build a genuine version-8 database holding presence schedules.
Migrations 1..8 run first — migration 1 still creates the retired
presence_schedules table — then five children, the schedules and
overrides above are written through the old table's column list.
"""
assert _migrate(database, MIGRATIONS[:8]) == 8
tables = _tables(database)
assert "presence_schedules" in tables
assert "presence_patterns" not in tables
for child_id in range(1, 6):
_run(
database.execute(
"INSERT INTO children (id, display_name, created_at) "
"VALUES (?, ?, '2026-01-01T00:00:00+00:00')",
(child_id, f"Child {child_id}"),
)
)
for row in _V8_SCHEDULES:
_run(
database.execute(
"INSERT INTO presence_schedules (id, child_id, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (?, ?, ?, ?, ?)",
row,
)
)
for row in _V8_OVERRIDES:
_run(
database.execute(
"INSERT INTO presence_overrides (child_id, start_date, "
"end_date, is_present) VALUES (?, ?, ?, ?)",
row,
)
)
def _v8_presence(database, child_id: int, day: datetime.date) -> bool:
"""Presence as a version-8 build answered it, read from the OLD rows.
The retired single-schedule rule, spelled out independently of the
new engine: a covering override wins; a child with no schedule row
is present; otherwise present iff the weekday is in the segment of
the anchor-arithmetic cycle week.
"""
iso = day.isoformat()
override = _run(
database.fetch_one(
"SELECT is_present FROM presence_overrides WHERE child_id = ? "
"AND start_date <= ? AND end_date >= ? "
"ORDER BY start_date DESC LIMIT 1",
(child_id, iso, iso),
)
)
if override is not None:
return bool(override[0])
row = _run(
database.fetch_one(
"SELECT cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules WHERE child_id = ?",
(child_id,),
)
)
if row is None:
return True
cycle, anchor, pattern = row
days = (day - datetime.date.fromisoformat(anchor)).days
segment = pattern.split("|")[(days // 7) % cycle]
return str(day.weekday()) in segment.split(",")
def _v9_engine(database):
"""Build the new PresenceEngine from the migrated tables."""
from custom_components.nestquest.core.dao_presence import (
PresenceOverridesDao,
PresencePatternsDao,
)
from custom_components.nestquest.core.presence import (
PresenceEngine,
PresenceOverride,
)
from custom_components.nestquest.core.presence_management import (
decode_pattern_record,
)
patterns = {}
overrides = {}
for child_id in range(1, 6):
records = _run(PresencePatternsDao(database).list_by_child(child_id))
if records:
patterns[child_id] = [decode_pattern_record(r) for r in records]
stored = _run(
PresenceOverridesDao(database).list_filtered(child_id=child_id)
)
if stored:
overrides[child_id] = [
PresenceOverride(
r.child_id, r.start_date, r.end_date, r.is_present
)
for r in stored
]
return PresenceEngine(patterns, overrides)
#: Every day from 2023-12-25 through 2027-01-10: before and after each
#: anchor, across three year boundaries and the 2024 leap day.
_COMPARE_DAYS = [
datetime.date(2023, 12, 25) + datetime.timedelta(days=offset)
for offset in range(
(datetime.date(2027, 1, 10) - datetime.date(2023, 12, 25)).days + 1
)
]
def test_v8_presence_is_unchanged_after_migration_9(tmp_path) -> None:
"""Presence for a pre-migration fixture is identical after migrating.
The old table is built and filled, every child's presence is
recorded day by day under the version-8 rule, the database is
migrated, and the new engine must answer the SAME for every child
(including the one with no schedule and the all-absent one) on
every day of the three-year window.
"""
database = _open_db(tmp_path / "v8-presence.db")
try:
_seed_v8_database(database)
before = {
(child_id, day): _v8_presence(database, child_id, day)
for child_id in range(1, 6)
for day in _COMPARE_DAYS
}
# The fixture must exercise both answers for the scheduled
# children, or "unchanged" would be vacuous.
for child_id in (1, 2, 3):
answers = {
before[(child_id, day)] for day in _COMPARE_DAYS
}
assert answers == {True, False}
assert _migrate(database) == 9
engine = _v9_engine(database)
after = {
(child_id, day): engine.is_present(child_id, day)
for child_id in range(1, 6)
for day in _COMPARE_DAYS
}
changed = [key for key in before if before[key] != after[key]]
assert changed == []
finally:
_run(database.close())
def test_migration_9_copies_every_schedule_verbatim(tmp_path) -> None:
database = _open_db(tmp_path / "v8-copy.db")
try:
_seed_v8_database(database)
assert _migrate(database) == 9
tables = _tables(database)
assert "presence_schedules" not in tables
assert "presence_patterns" in tables
rows = _run(
database.fetch_all(
"SELECT id, child_id, name, kind, cycle_length_weeks, "
"anchor_date, pattern FROM presence_patterns ORDER BY id"
)
)
assert rows == [
(
schedule_id,
child_id,
MIGRATED_PRESENCE_PATTERN_NAME,
"home",
cycle,
anchor,
pattern,
)
for schedule_id, child_id, cycle, anchor, pattern in _V8_SCHEDULES
]
assert MIGRATED_PRESENCE_PATTERN_NAME == "Home schedule"
assert _counts(database)["presence_overrides"] == len(_V8_OVERRIDES)
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
finally:
_run(database.close())
def test_migrated_database_accepts_several_patterns_per_child(
tmp_path,
) -> None:
"""The migrated table drops the retired UNIQUE(child_id)."""
database = _open_db(tmp_path / "v8-multi.db")
try:
_seed_v8_database(database)
_migrate(database)
_run(
database.execute(
"INSERT INTO presence_patterns (child_id, name, kind, "
"cycle_length_weeks, anchor_date, pattern) "
"VALUES (1, 'Thu+Fri away', 'away', 1, '2026-01-05', '3,4')"
)
)
row = _run(
database.fetch_one(
"SELECT COUNT(*) FROM presence_patterns WHERE child_id = 1"
)
)
assert row == (2,)
finally:
_run(database.close())
def test_fresh_file_walks_through_and_retires_presence_schedules(
tmp_path,
) -> None:
"""Migration 1 still makes the old table; migration 9 retires it."""
database = _open_db(tmp_path / "fresh-v9.db")
try:
assert _migrate(database, MIGRATIONS[:8]) == 8
assert "presence_schedules" in _tables(database)
assert "presence_patterns" not in _tables(database)
assert _migrate(database) == 9
assert "presence_schedules" not in _tables(database)
assert _counts(database)["presence_patterns"] == 0
finally:
_run(database.close())
def test_migrated_presence_patterns_match_the_canonical_ddl(tmp_path) -> None:
"""A migrated table and one made from schema.py are the same shape."""
migrated = _open_db(tmp_path / "migrated-shape.db")
canonical = _open_db(tmp_path / "canonical-shape.db")
try:
_migrate(migrated)
for sql in SCHEMA_V1_STATEMENTS:
_run(canonical.execute(sql))
for pragma in ("table_info", "foreign_key_list", "index_list"):
assert _run(
migrated.fetch_all(f"PRAGMA {pragma}(presence_patterns)")
) == _run(
canonical.fetch_all(f"PRAGMA {pragma}(presence_patterns)")
)
finally:
_run(migrated.close())
_run(canonical.close())
def test_failed_migration_9_keeps_the_schedules(tmp_path) -> None:
"""Migration 9 is atomic: a failure after the copy loses nothing.
The real migration 9 statements are followed by a broken one in
the SAME step, so the create, the copy and the drop have all run
when it fails — the runner's one transaction must roll every one
of them back together with the stamp.
"""
database = _open_db(tmp_path / "v8-atomic.db")
try:
_seed_v8_database(database)
broken = [
*MIGRATIONS[:8],
[*MIGRATION_9_PRESENCE_PATTERNS, "CREATE TABLE broken ("],
]
with pytest.raises(sqlite3.OperationalError):
_migrate(database, broken)
assert _version(database) == 8
tables = _tables(database)
assert "presence_patterns" not in tables
rows = _run(
database.fetch_all(
"SELECT id, child_id, cycle_length_weeks, anchor_date, "
"pattern FROM presence_schedules ORDER BY id"
)
)
assert rows == list(_V8_SCHEDULES)
assert _migrate(database) == 9
assert _counts(database)["presence_patterns"] == len(_V8_SCHEDULES)
finally:
_run(database.close())
# ---------------------------------------------------------------------------
# Idempotence: second start is a no-op
# ---------------------------------------------------------------------------
+319 -136
View File
@@ -7,133 +7,173 @@ import pytest
from custom_components.nestquest.core.presence import (
MAX_CYCLE_LENGTH_WEEKS,
PATTERN_KINDS,
PresenceEngine,
PresenceOverride,
PresenceSchedule,
PresencePattern,
cycle_week_index,
)
ANCHOR = datetime.date(2026, 1, 5) # a Monday
#: Every pre-schema-9 single schedule is exactly one ``home`` pattern
#: (migration 9 carries them across that way), so the model and engine
#: tests below use this name/kind unless they are about name or kind.
NAME = "Home schedule"
HOME = "home"
TWO_WEEK_PATTERN = {0: {0, 2, 4}, 1: {1, 3}}
# ---------------------------------------------------------------------------
# PresenceSchedule construction
# PresencePattern construction
# ---------------------------------------------------------------------------
def test_schedule_holds_validated_fields() -> None:
schedule = PresenceSchedule(
3, 2, "2026-01-05", {0: {0, 2, 4}, 1: {1, 3}}
def test_pattern_holds_validated_fields() -> None:
pattern = PresencePattern(
3, "Dad's weekends", "away", 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})}
assert pattern.child_id == 3
assert pattern.name == "Dad's weekends"
assert pattern.kind == "away"
assert pattern.cycle_length_weeks == 2
assert pattern.anchor_date == ANCHOR
assert pattern.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_pattern_accepts_date_objects() -> None:
pattern = PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})
assert pattern.anchor_date is ANCHOR
def test_schedule_rejects_cycle_length_below_one() -> None:
def test_pattern_name_is_stored_trimmed() -> None:
pattern = PresencePattern(1, " Mum's week \t", HOME, 1, ANCHOR, {0: {0}})
assert pattern.name == "Mum's week"
@pytest.mark.parametrize("bad", ["", " ", "\t\n"])
def test_pattern_rejects_blank_name(bad) -> None:
with pytest.raises(ValueError, match="name must not be empty"):
PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}})
@pytest.mark.parametrize("bad", [None, 5, b"Home", ["Home"]])
def test_pattern_rejects_non_string_name(bad) -> None:
with pytest.raises(ValueError, match="name must be a string"):
PresencePattern(1, bad, HOME, 1, ANCHOR, {0: {0}})
def test_pattern_kinds_are_home_and_away() -> None:
"""The model's kinds match the schema's CHECK exactly."""
assert PATTERN_KINDS == ("home", "away")
for kind in PATTERN_KINDS:
assert PresencePattern(1, NAME, kind, 1, ANCHOR, {0: {0}}).kind == kind
@pytest.mark.parametrize("bad", ["Home", "AWAY", "present", "", None, True, 1])
def test_pattern_rejects_unknown_kind(bad) -> None:
with pytest.raises(ValueError, match="kind must be one of home, away"):
PresencePattern(1, NAME, bad, 1, ANCHOR, {0: {0}})
def test_pattern_rejects_cycle_length_below_one() -> None:
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, 0, ANCHOR, {})
PresencePattern(1, NAME, HOME, 0, ANCHOR, {})
with pytest.raises(ValueError, match="between 1 and"):
PresenceSchedule(1, -1, ANCHOR, {})
PresencePattern(1, NAME, HOME, -1, ANCHOR, {})
def test_schedule_rejects_cycle_length_above_documented_cap() -> None:
def test_pattern_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, {})
PresencePattern(1, NAME, HOME, 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:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, bad, ANCHOR, {0: {0}})
def test_schedule_rejects_pattern_missing_week_indices() -> None:
def test_pattern_rejects_pattern_missing_week_indices() -> None:
with pytest.raises(ValueError, match="missing \\[1\\]"):
PresenceSchedule(1, 2, ANCHOR, {0: {0, 2}})
PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}})
def test_schedule_rejects_pattern_with_extra_week_indices() -> None:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}, 2: {2}})
def test_schedule_rejects_pattern_with_non_int_week_keys() -> None:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, 1, ANCHOR, {"0": {0}})
def test_schedule_rejects_pattern_with_bool_week_key() -> None:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, 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:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {bad_weekday}})
@pytest.mark.parametrize("bad", [True, "1", 1.5, None])
def test_schedule_rejects_non_int_weekdays(bad) -> None:
def test_pattern_rejects_non_int_weekdays(bad) -> None:
with pytest.raises(ValueError, match="weekday must be an int"):
PresenceSchedule(1, 1, ANCHOR, {0: [bad]})
PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: [bad]})
def test_schedule_rejects_string_and_none_week_sets() -> None:
def test_pattern_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"})
PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: "02"})
with pytest.raises(ValueError, match="iterable of weekday ints"):
PresenceSchedule(1, 1, ANCHOR, {0: None})
PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: None})
def test_schedule_rejects_non_dict_pattern() -> None:
def test_pattern_rejects_non_dict_pattern() -> None:
with pytest.raises(ValueError, match="must map week index"):
PresenceSchedule(1, 1, ANCHOR, "0,2")
PresencePattern(1, NAME, HOME, 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:
def test_pattern_rejects_non_int_child_id(bad) -> None:
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceSchedule(bad, 1, ANCHOR, {0: {0}})
PresencePattern(bad, NAME, HOME, 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:
def test_pattern_rejects_non_strict_anchor_dates(bad) -> None:
with pytest.raises(ValueError, match="anchor_date"):
PresenceSchedule(1, 1, bad, {0: {0}})
PresencePattern(1, NAME, HOME, 1, bad, {0: {0}})
def test_schedule_empty_week_means_absent_week() -> None:
def test_pattern_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()
distinct from having no pattern (the engine's default)."""
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert pattern.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_pattern_empty_week_encodes_as_empty_segment() -> None:
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: set()})
assert pattern.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}})
def test_pattern_equal_patterns_are_equal_and_encode_identically() -> None:
one = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {4, 2, 0}, 1: {3, 1}})
two = PresencePattern(2, NAME, HOME, 2, ANCHOR, {0: {0, 2, 4}, 1: {1, 3}})
assert one.encode() == two.encode() == "0,2,4|1,3"
@@ -154,34 +194,60 @@ def test_schedule_equal_patterns_are_equal_and_encode_identically() -> None:
"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_pattern_decode_encode_round_trips_losslessly(encoded) -> None:
pattern = PresencePattern.decode(1, NAME, HOME, ANCHOR, encoded)
assert pattern.encode() == encoded
again = PresencePattern.decode(1, NAME, HOME, ANCHOR, pattern.encode())
assert again == pattern
def test_schedule_decode_sets_cycle_length_from_segments() -> None:
one_week = PresenceSchedule.decode(1, ANCHOR, "0,2,4")
def test_pattern_decode_sets_cycle_length_from_segments() -> None:
one_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0,2,4")
assert one_week.cycle_length_weeks == 1
four_week = PresenceSchedule.decode(1, ANCHOR, "0|1|2|3,4")
four_week = PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3,4")
assert four_week.cycle_length_weeks == 4
def test_schedule_decode_rejects_malformed_segments() -> None:
def test_pattern_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)
PresencePattern.decode(1, NAME, HOME, ANCHOR, bad)
def test_schedule_decode_rejects_non_string() -> None:
def test_pattern_decode_rejects_non_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
PresenceSchedule.decode(1, ANCHOR, None)
PresencePattern.decode(1, NAME, HOME, ANCHOR, None)
def test_schedule_decode_rejects_too_many_segments(tmp_path) -> None:
def test_pattern_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")
PresencePattern.decode(1, NAME, HOME, ANCHOR, "0|1|2|3|4")
def test_pattern_decode_carries_and_validates_name_and_kind() -> None:
"""decode is ordinary construction: name trimmed, kind checked."""
decoded = PresencePattern.decode(1, " Away days ", "away", ANCHOR, "3,4")
assert (decoded.name, decoded.kind) == ("Away days", "away")
with pytest.raises(ValueError, match="name must not be empty"):
PresencePattern.decode(1, " ", "away", ANCHOR, "3,4")
with pytest.raises(ValueError, match="kind must be one of"):
PresencePattern.decode(1, NAME, "absent", ANCHOR, "3,4")
def test_pattern_covers_follows_the_cycle_week() -> None:
"""covers() is the weekday-in-this-cycle-week test, independent of
kind: an away pattern covers its days exactly like a home one."""
for kind in PATTERN_KINDS:
pattern = PresencePattern(1, NAME, kind, 2, ANCHOR, {0: {1, 3}, 1: {2}})
# Tue Jan 6: week 0, weekday 1 -> covered.
assert pattern.covers(datetime.date(2026, 1, 6)) is True
# Wed Jan 7: week 0, weekday 2 -> not covered.
assert pattern.covers(datetime.date(2026, 1, 7)) is False
# Wed Jan 14: week 1, weekday 2 -> covered.
assert pattern.covers(datetime.date(2026, 1, 14)) is True
# Tue Dec 30 2025: before the anchor, walks back into week 1.
assert pattern.covers(datetime.date(2025, 12, 30)) is False
assert pattern.covers(datetime.date(2025, 12, 31)) is True
# ---------------------------------------------------------------------------
@@ -231,11 +297,11 @@ def test_override_rejects_non_strict_dates(start, end) -> None:
PresenceOverride(1, start, end, True)
def test_schedule_rejects_datetime_anchor() -> None:
def test_pattern_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}})
PresencePattern(1, NAME, HOME, 1, datetime.datetime(2026, 1, 5, 9, 30), {0: {0}})
def test_override_rejects_datetime_bounds() -> None:
@@ -245,23 +311,23 @@ def test_override_rejects_datetime_bounds() -> None:
)
def test_schedule_pattern_is_immutable_after_construction() -> None:
"""The frozen schedule must not leak a mutable dict: injecting
def test_pattern_pattern_is_immutable_after_construction() -> None:
"""The frozen pattern 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}})
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0, 2}, 1: {1}})
with pytest.raises(TypeError):
schedule.pattern[0] = {9}
pattern.pattern[0] = {9}
with pytest.raises(TypeError):
del schedule.pattern[1]
del pattern.pattern[1]
# mappingproxy exposes no mutators at all.
assert not any(
hasattr(schedule.pattern, name)
hasattr(pattern.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"
assert pattern.pattern[0] == frozenset({0, 2})
assert pattern.encode() == "0,2|1"
# ---------------------------------------------------------------------------
@@ -270,13 +336,13 @@ def test_schedule_pattern_is_immutable_after_construction() -> None:
def test_cycle_week_index_follows_the_anchor_formula() -> None:
schedule = PresenceSchedule(1, 2, ANCHOR, {0: {0}, 1: {1}})
pattern = PresencePattern(1, NAME, HOME, 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)
pattern, ANCHOR + datetime.timedelta(days=offset)
)
== expected
), f"offset {offset}"
@@ -285,18 +351,18 @@ def test_cycle_week_index_follows_the_anchor_formula() -> None:
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
pattern = PresencePattern(1, NAME, HOME, 2, ANCHOR, {0: {0}, 1: {1}})
assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=1)) == 1
assert cycle_week_index(pattern, ANCHOR - datetime.timedelta(days=7)) == 1
assert cycle_week_index(pattern, 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}}
pattern = PresencePattern(
1, NAME, HOME, 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] == [
assert [cycle_week_index(pattern, ANCHOR + datetime.timedelta(days=o)) for o in offsets] == [
0, 1, 2, 0, 1
]
@@ -307,8 +373,8 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
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}}
pattern = PresencePattern(
1, NAME, HOME, 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
@@ -357,7 +423,7 @@ def test_cycle_week_index_stable_across_three_year_boundaries() -> None:
datetime.date(2022, 1, 7): 0,
}
for day, wanted in expected.items():
got = cycle_week_index(schedule, day)
got = cycle_week_index(pattern, 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
@@ -388,21 +454,22 @@ def test_module_uses_no_iso_week_functions() -> None:
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)."""
"""Two children on opposite weeks of one two-week rotation (each a
single ``home`` pattern), one with no pattern 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()}
declan = PresencePattern(
1, NAME, HOME, 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}}
jordyn = PresencePattern(
2, NAME, HOME, 2, anchor, {0: set(), 1: {0, 1, 2, 3, 4, 5, 6}}
)
return PresenceEngine({1: declan, 2: jordyn})
return PresenceEngine({1: [declan], 2: [jordyn]})
def test_engine_child_without_schedule_is_always_present() -> None:
def test_engine_child_without_pattern_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
expected verdict: Chloe (no pattern) always present; the two
rotation children complementary per the anchor pattern."""
engine = _three_child_engine()
anchor = datetime.date(2026, 1, 5)
@@ -446,9 +513,12 @@ def test_engine_weekend_absence_respects_the_pattern() -> None:
child's present week."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}}
)
1: [
PresencePattern(
1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {1, 3}, 1: {2, 4}},
)
]
}
)
# 2026-01-06 (Tue) is week 0, weekday 1 -> present.
@@ -460,18 +530,24 @@ def test_engine_weekend_absence_respects_the_pattern() -> None:
def test_engine_rejects_bad_construction_and_inputs() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
pattern = PresencePattern(1, NAME, HOME, 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})
PresenceEngine([pattern])
# Values are LISTS now: a bare pattern (the retired one-schedule
# shape) is refused rather than silently iterated.
with pytest.raises(ValueError, match="must be a list of PresencePattern"):
PresenceEngine({1: pattern})
with pytest.raises(ValueError, match="must be a list of PresencePattern"):
PresenceEngine({1: (pattern,)})
with pytest.raises(ValueError, match="entries must be PresencePattern"):
PresenceEngine({1: ["not-a-pattern"]})
with pytest.raises(ValueError, match="holds a pattern of child 1"):
PresenceEngine({2: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({True: schedule})
PresenceEngine({True: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1.0: PresenceSchedule(1, 1, ANCHOR, {0: {0}})})
engine = PresenceEngine({1: schedule})
PresenceEngine({1.0: [PresencePattern(1, NAME, HOME, 1, ANCHOR, {0: {0}})]})
engine = PresenceEngine({1: [pattern]})
with pytest.raises(ValueError, match="child_id must be an integer"):
engine.is_present(True, ANCHOR)
with pytest.raises(ValueError, match="plain calendar date"):
@@ -481,11 +557,111 @@ def test_engine_rejects_bad_construction_and_inputs() -> None:
def test_engine_is_immutable() -> None:
engine = _three_child_engine()
with pytest.raises(AttributeError, match="immutable snapshot"):
engine._schedules = {}
engine._patterns = {}
with pytest.raises(AttributeError, match="immutable snapshot"):
engine.something_new = 1
# ---------------------------------------------------------------------------
# PresenceEngine: combining several patterns per child
# ---------------------------------------------------------------------------
def _pattern(kind: str, pattern: dict, *, cycle: int = 1) -> PresencePattern:
return PresencePattern(1, f"{kind} pattern", kind, cycle, ANCHOR, pattern)
def test_engine_empty_pattern_list_is_always_present() -> None:
"""An empty list is the same as no entry: present every day."""
engine = PresenceEngine({1: []})
for offset in range(14):
day = ANCHOR + datetime.timedelta(days=offset)
assert engine.is_present(1, day) is True, f"offset {offset}"
def test_engine_away_only_child_is_present_except_covered_days() -> None:
"""Away patterns alone never make an uncovered day absent: the
household default (present) holds outside their coverage."""
engine = PresenceEngine({1: [_pattern("away", {0: {3, 4}})]})
for offset in range(14):
day = ANCHOR + datetime.timedelta(days=offset)
expected = day.weekday() not in {3, 4}
assert engine.is_present(1, day) is expected, f"{day}"
def test_engine_several_home_patterns_union_their_days() -> None:
"""Any covering home pattern makes the day present; a day no home
pattern covers is absent once the child has a home pattern."""
engine = PresenceEngine(
{
1: [
_pattern("home", {0: {0, 1}}),
_pattern("home", {0: {4}, 1: set()}, cycle=2),
]
}
)
# Week 0 (Jan 5-11): Mon, Tue from the first, Fri from the second.
present = {
ANCHOR + datetime.timedelta(days=offset)
for offset in range(7)
if engine.is_present(1, ANCHOR + datetime.timedelta(days=offset))
}
assert present == {
datetime.date(2026, 1, 5),
datetime.date(2026, 1, 6),
datetime.date(2026, 1, 9),
}
# Week 1: the second pattern's empty week leaves only Mon/Tue.
assert engine.is_present(1, datetime.date(2026, 1, 16)) is False
assert engine.is_present(1, datetime.date(2026, 1, 12)) is True
def test_engine_away_pattern_beats_covering_home_pattern() -> None:
"""Rule order: a covering away pattern wins over a covering home
pattern, whichever is listed first."""
home = _pattern("home", {0: {0, 1, 2, 3, 4, 5, 6}})
away = _pattern("away", {0: {5, 6}})
for patterns in ([home, away], [away, home]):
engine = PresenceEngine({1: patterns})
assert engine.is_present(1, datetime.date(2026, 1, 9)) is True # Fri
assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat
assert engine.is_present(1, datetime.date(2026, 1, 11)) is False # Sun
def test_engine_home_pattern_makes_uncovered_days_absent_even_with_away() -> None:
"""Mixing kinds: a day neither pattern covers is absent because the
child has a home pattern (rule 4), not present."""
engine = PresenceEngine(
{1: [_pattern("home", {0: {0, 1}}), _pattern("away", {0: {5}})]}
)
assert engine.is_present(1, datetime.date(2026, 1, 5)) is True # Mon
assert engine.is_present(1, datetime.date(2026, 1, 7)) is False # Wed
assert engine.is_present(1, datetime.date(2026, 1, 10)) is False # Sat
def test_engine_override_beats_away_pattern() -> None:
engine = PresenceEngine(
{1: [_pattern("away", {0: {5, 6}})]},
overrides={
1: [PresenceOverride(1, "2026-01-10", "2026-01-10", True)]
},
)
assert engine.is_present(1, datetime.date(2026, 1, 10)) is True
assert engine.is_present(1, datetime.date(2026, 1, 11)) is False
def test_engine_pattern_lists_are_frozen() -> None:
"""Mutating the caller's pattern list after construction must not
change the engine's answers."""
patterns = [_pattern("home", {0: {0}})]
engine = PresenceEngine({1: patterns})
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
patterns.append(_pattern("home", {0: {1}}))
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
with pytest.raises(AttributeError):
engine._patterns[1].append(patterns[1])
# ---------------------------------------------------------------------------
# Overrides take precedence over the pattern
# ---------------------------------------------------------------------------
@@ -495,9 +671,12 @@ 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()}
)
1: [
PresencePattern(
1, NAME, HOME, 2, anchor,
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
]
},
overrides={1: list(overrides_for_declan)} if overrides_for_declan else {},
)
@@ -523,8 +702,8 @@ def test_present_override_on_normally_absent_day_wins() -> None:
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
def test_override_applies_without_a_pattern() -> None:
"""An override on a pattern-free child still applies: holidays and
swaps must work for the always-present sibling too."""
engine = PresenceEngine(
{},
@@ -554,18 +733,18 @@ def test_override_range_beats_pattern_for_its_span() -> None:
def test_engine_override_snapshot_validation() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
patterns = [PresencePattern(1, NAME, HOME, 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])
PresenceEngine({1: patterns}, overrides=[entry])
with pytest.raises(ValueError, match="must be a list"):
PresenceEngine({1: schedule}, overrides={1: entry})
PresenceEngine({1: patterns}, overrides={1: entry})
with pytest.raises(ValueError, match="must be"):
PresenceEngine({1: schedule}, overrides={1: ["nope"]})
PresenceEngine({1: patterns}, overrides={1: ["nope"]})
with pytest.raises(ValueError, match="holds an override"):
PresenceEngine({1: schedule}, overrides={2: [entry]})
PresenceEngine({1: patterns}, overrides={2: [entry]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1: schedule}, overrides={True: [entry]})
PresenceEngine({1: patterns}, overrides={True: [entry]})
def test_engine_overlapping_snapshot_uses_latest_start() -> None:
@@ -591,11 +770,11 @@ def test_engine_override_lists_are_frozen() -> None:
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()}
pattern = PresencePattern(
1, NAME, HOME, 2, ANCHOR, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
engine = PresenceEngine(
{1: schedule}, overrides={1: entries} # caller-owned list
{1: [pattern]}, 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))
@@ -620,10 +799,12 @@ def _preview_engine() -> PresenceEngine:
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()},
)
1: [
PresencePattern(
1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
]
}
)
@@ -669,12 +850,12 @@ def test_preview_start_date_is_inclusive() -> None:
def test_preview_honours_overrides() -> None:
schedule = PresenceSchedule(
1, 2, datetime.date(2026, 1, 5),
pattern = PresencePattern(
1, NAME, HOME, 2, datetime.date(2026, 1, 5),
{0: {0, 1, 2, 3, 4, 5, 6}, 1: set()},
)
engine = PresenceEngine(
{1: schedule},
{1: [pattern]},
overrides={
1: [
PresenceOverride(
@@ -692,7 +873,7 @@ def test_preview_honours_overrides() -> None:
]
def test_preview_schedule_free_child_returns_consecutive_days() -> None:
def test_preview_pattern_free_child_returns_consecutive_days() -> None:
engine = PresenceEngine({})
preview = engine.next_present_dates(3, "2026-03-01", 3)
assert preview == [
@@ -719,9 +900,11 @@ def test_preview_always_absent_child_raises_not_loops() -> None:
hang the admin preview."""
engine = PresenceEngine(
{
1: PresenceSchedule(
1, 1, datetime.date(2026, 1, 5), {0: set()}
)
1: [
PresencePattern(
1, NAME, HOME, 1, datetime.date(2026, 1, 5), {0: set()}
)
]
}
)
with pytest.raises(ValueError, match="not present on any"):
@@ -767,10 +950,10 @@ def test_custody_rotation_walks_full_years_without_inversion(
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()}
rotation = PresencePattern(
1, NAME, HOME, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
engine = PresenceEngine({1: rotation})
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
+165
View File
@@ -0,0 +1,165 @@
"""Tests for the schema-9 multi-pattern presence engine.
A child carries zero or more :class:`PresencePattern` rules; the
engine resolves them override -> away -> home -> default. The
expectations below are written out literally, week by week, rather
than recomputed with the engine's own arithmetic, so a wrong cycle
position cannot agree with itself.
"""
from __future__ import annotations
import datetime
from custom_components.nestquest.core.presence import (
PresenceEngine,
PresenceOverride,
PresencePattern,
)
CHILD = 1
#: The owner's rules, both anchored on Monday 2026-09-07 (cycle week 0).
AWAY_THU_FRI = PresencePattern(
CHILD, "Thursdays and Fridays away", "away", 1, "2026-09-07", {0: {3, 4}}
)
AWAY_ALTERNATE_WEEKENDS = PresencePattern(
CHILD, "Weekends away", "away", 2, "2026-09-07", {0: {5, 6}, 1: set()}
)
def _owner_engine(overrides=None) -> PresenceEngine:
return PresenceEngine(
{CHILD: [AWAY_THU_FRI, AWAY_ALTERNATE_WEEKENDS]}, overrides
)
def _week(engine: PresenceEngine, monday: str) -> str:
"""Render one Monday..Sunday week as ``P`` (present) / ``A`` (away)."""
start = datetime.date.fromisoformat(monday)
assert start.weekday() == 0, monday
return "".join(
"P" if engine.is_present(CHILD, start + datetime.timedelta(days=d))
else "A"
for d in range(7)
)
def test_owner_scenario_day_by_day_across_several_weeks() -> None:
"""Away Thu+Fri every week, plus away Sat+Sun every other week."""
engine = _owner_engine()
# Mon..Sun
expected = {
"2026-08-31": "PPPAAPP", # cycle week 1, before the anchor
"2026-09-07": "PPPAAAA", # anchor week: weekend away
"2026-09-14": "PPPAAPP",
"2026-09-21": "PPPAAAA",
"2026-09-28": "PPPAAPP",
"2026-10-05": "PPPAAAA",
"2026-10-12": "PPPAAPP",
"2026-10-19": "PPPAAAA",
}
assert {monday: _week(engine, monday) for monday in expected} == expected
def test_owner_scenario_across_the_year_boundary() -> None:
"""2026 has 53 ISO weeks; the alternation must not skip a beat."""
engine = _owner_engine()
expected = {
"2026-12-21": "PPPAAPP", # Dec 26-27 home
"2026-12-28": "PPPAAAA", # Jan 1 (Fri) and Jan 2-3 away
"2027-01-04": "PPPAAPP", # Jan 9-10 home
"2027-01-11": "PPPAAAA", # Jan 16-17 away
}
assert {monday: _week(engine, monday) for monday in expected} == expected
def test_owner_scenario_through_a_leap_day() -> None:
"""February 29th 2028 is an ordinary Tuesday in the cycle."""
engine = _owner_engine()
expected = {
"2028-02-21": "PPPAAAA", # Feb 26-27 away
"2028-02-28": "PPPAAPP", # Tue Feb 29 home; Mar 4-5 home
"2028-03-06": "PPPAAAA", # Mar 11-12 away
}
assert {monday: _week(engine, monday) for monday in expected} == expected
assert engine.is_present(CHILD, datetime.date(2028, 2, 29)) is True
def test_cycle_position_ignores_iso_week_parity() -> None:
"""D-004: consecutive Saturdays in odd ISO weeks 53 and 1 alternate.
2027-01-02 sits in ISO week 53 of 2026 and 2027-01-09 in ISO week 1
of 2027 — both odd, so a week-parity rule would give them the SAME
answer. Anchor-date arithmetic puts them in consecutive cycle
weeks, so the alternate-weekend pattern covers exactly one.
"""
first = datetime.date(2027, 1, 2)
second = datetime.date(2027, 1, 9)
assert first.isocalendar()[1] % 2 == second.isocalendar()[1] % 2 == 1
assert AWAY_ALTERNATE_WEEKENDS.covers(first) is True
assert AWAY_ALTERNATE_WEEKENDS.covers(second) is False
engine = _owner_engine()
assert engine.is_present(CHILD, first) is False
assert engine.is_present(CHILD, second) is True
def test_child_with_no_patterns_is_present_every_day() -> None:
engine = _owner_engine()
other = 2
start = datetime.date(2026, 12, 1)
for offset in range(120):
assert engine.is_present(other, start + datetime.timedelta(days=offset))
empty = PresenceEngine({})
assert all(
empty.is_present(CHILD, start + datetime.timedelta(days=offset))
for offset in range(120)
)
def test_overrides_beat_patterns_both_ways() -> None:
engine = _owner_engine(
{
CHILD: [
# A Thursday the away pattern covers: present override.
PresenceOverride(CHILD, "2026-09-10", "2026-09-10", True),
# A Monday no pattern covers: absent override.
PresenceOverride(CHILD, "2026-09-14", "2026-09-14", False),
]
}
)
assert engine.is_present(CHILD, datetime.date(2026, 9, 10)) is True
assert engine.is_present(CHILD, datetime.date(2026, 9, 14)) is False
# The day after each override the patterns answer again.
assert engine.is_present(CHILD, datetime.date(2026, 9, 11)) is False
assert engine.is_present(CHILD, datetime.date(2026, 9, 15)) is True
def test_away_beats_home_where_both_cover_a_date() -> None:
home_every_day = PresencePattern(
CHILD, "Home schedule", "home", 1, "2026-09-07",
{0: {0, 1, 2, 3, 4, 5, 6}},
)
engine = PresenceEngine({CHILD: [home_every_day, AWAY_THU_FRI]})
assert _week(engine, "2026-09-07") == "PPPAAPP"
# Order of the patterns does not matter.
reversed_engine = PresenceEngine({CHILD: [AWAY_THU_FRI, home_every_day]})
assert _week(reversed_engine, "2026-09-07") == "PPPAAPP"
def test_home_patterns_mark_uncovered_days_away() -> None:
"""A home pattern lists the days the child IS here (schedule semantics).
With at least one home pattern, a date no pattern covers is away —
the retired single-schedule behaviour migration 9 preserves. Two
home patterns union their covered days.
"""
weekdays = PresencePattern(
CHILD, "School weeks", "home", 1, "2026-09-07", {0: {0, 1, 2}}
)
alternate_weekends = PresencePattern(
CHILD, "Alternate weekends", "home", 2, "2026-09-07",
{0: {5, 6}, 1: set()},
)
engine = PresenceEngine({CHILD: [weekdays, alternate_weekends]})
assert _week(engine, "2026-09-07") == "PPPAAPP"
assert _week(engine, "2026-09-14") == "PPPAAAA"
+130 -46
View File
@@ -13,7 +13,7 @@ from custom_components.nestquest.core.schema import (
SCHEMA_V1_COMPLETION_EVENTS_DDL,
SCHEMA_V7_META_STATE_DDL,
SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
SCHEMA_V9_PRESENCE_PATTERNS_DDL,
SCHEMA_V1_SCHEDULE_RULES_DDL,
SCHEMA_V1_STATEMENTS,
SCHEMA_V1_QUEST_DEFINITIONS_DDL,
@@ -112,14 +112,16 @@ def _insert_definition(database, rule_id=1, assignees=(1,), **overrides):
return result.lastrowid
def _insert_schedule(database, child_id, **overrides):
"""Insert one valid presence_schedule, returning the schedule id.
def _insert_pattern(database, child_id, **overrides):
"""Insert one valid presence_pattern, returning the pattern id.
Pass ``_OMIT`` as a value to leave that column out of the INSERT so
its DDL DEFAULT applies.
"""
values: dict[str, object] = {
"child_id": child_id,
"name": "Home schedule",
"kind": "home",
"cycle_length_weeks": 2,
"anchor_date": "2026-09-13",
"pattern": "0,2,4|1,3",
@@ -130,7 +132,7 @@ def _insert_schedule(database, child_id, **overrides):
placeholders = ", ".join("?" for _ in values)
result = _run(
database.execute(
f"INSERT INTO presence_schedules ({columns}) VALUES ({placeholders})",
f"INSERT INTO presence_patterns ({columns}) VALUES ({placeholders})",
tuple(values.values()),
)
)
@@ -177,7 +179,7 @@ def test_schema_v1_statements_compose_the_nine_tables() -> None:
*SCHEMA_V1_QUEST_DEFINITIONS_DDL,
*SCHEMA_V1_QUEST_DEFINITION_ASSIGNEES_DDL,
*SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL,
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
*SCHEMA_V9_PRESENCE_PATTERNS_DDL,
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
*SCHEMA_V1_QUEST_INSTANCES_DDL,
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
@@ -1317,7 +1319,7 @@ def test_applying_extended_ddl_twice_keeps_new_table_rows(tmp_path) -> None:
# ---------------------------------------------------------------------------
# presence_schedules and presence_overrides constraint enforcement
# presence_patterns and presence_overrides constraint enforcement
# ---------------------------------------------------------------------------
@@ -1331,17 +1333,26 @@ def _child(database, name="Ada") -> int:
return 1
def test_presence_schedules_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules.db")
def test_presence_patterns_table_exists_after_applying_ddl(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns.db")
try:
_apply(database)
row = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'presence_patterns'"
)
)
assert row is not None and row[0] == "presence_patterns"
# Schema 9 replaced the one-per-child presence_schedules table;
# the current-schema DDL must not create it.
gone = _run(
database.fetch_one(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name = 'presence_schedules'"
)
)
assert row is not None and row[0] == "presence_schedules"
assert gone is None
finally:
_run(database.close())
@@ -1361,15 +1372,17 @@ def test_presence_overrides_table_exists_after_applying_ddl(tmp_path) -> None:
_run(database.close())
def test_presence_schedules_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-columns.db")
def test_presence_patterns_columns_types_and_constraints(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-columns.db")
try:
_apply(database)
columns = _run(database.fetch_all("PRAGMA table_info(presence_schedules)"))
columns = _run(database.fetch_all("PRAGMA table_info(presence_patterns)"))
# cid, name, type, notnull, dflt_value, pk
assert [(row[1], row[2], row[3], row[4], row[5]) for row in columns] == [
("id", "INTEGER", 0, None, 1),
("child_id", "INTEGER", 1, None, 0),
("name", "TEXT", 1, None, 0),
("kind", "TEXT", 1, None, 0),
("cycle_length_weeks", "INTEGER", 1, None, 0),
("anchor_date", "TEXT", 1, None, 0),
("pattern", "TEXT", 1, None, 0),
@@ -1395,11 +1408,11 @@ def test_presence_overrides_columns_types_and_constraints(tmp_path) -> None:
_run(database.close())
def test_presence_schedules_foreign_key_declared(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-fks.db")
def test_presence_patterns_foreign_key_declared(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-fks.db")
try:
_apply(database)
fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_schedules)"))
fks = _run(database.fetch_all("PRAGMA foreign_key_list(presence_patterns)"))
declared = {(row[2], row[3], row[4]) for row in fks}
assert declared == {("children", "child_id", "id")}
finally:
@@ -1417,48 +1430,119 @@ def test_presence_overrides_foreign_key_declared(tmp_path) -> None:
_run(database.close())
def test_presence_schedules_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-valid.db")
def test_presence_patterns_valid_insert_round_trips(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-valid.db")
try:
_apply(database)
_child(database)
schedule_id = _insert_schedule(database, child_id=1)
pattern_id = _insert_pattern(database, child_id=1)
row = _run(
database.fetch_one(
"SELECT child_id, cycle_length_weeks, anchor_date, pattern "
"FROM presence_schedules WHERE id = ?",
(schedule_id,),
"SELECT child_id, name, kind, cycle_length_weeks, anchor_date, "
"pattern FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
)
assert row == (1, 2, "2026-09-13", "0,2,4|1,3")
assert row == (1, "Home schedule", "home", 2, "2026-09-13", "0,2,4|1,3")
finally:
_run(database.close())
def test_presence_schedules_second_schedule_for_same_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-unique.db")
def test_presence_patterns_allow_several_patterns_per_child(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-many.db")
try:
_apply(database)
_child(database)
_insert_schedule(database, child_id=1)
with pytest.raises(sqlite3.IntegrityError, match="UNIQUE"):
_insert_schedule(database, child_id=1)
first = _insert_pattern(database, child_id=1)
second = _insert_pattern(
database, child_id=1, name="Weekend away", kind="away",
cycle_length_weeks=1, pattern="5,6",
)
third = _insert_pattern(database, child_id=1) # identical row is fine
rows = _run(
database.fetch_all(
"SELECT id, name, kind FROM presence_patterns "
"WHERE child_id = 1 ORDER BY id"
)
)
assert rows == [
(first, "Home schedule", "home"),
(second, "Weekend away", "away"),
(third, "Home schedule", "home"),
]
finally:
_run(database.close())
def test_presence_schedules_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-bad-child.db")
def test_presence_patterns_declare_no_unique_index(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-no-unique.db")
try:
_apply(database)
indexes = _run(database.fetch_all("PRAGMA index_list(presence_patterns)"))
assert [row for row in indexes if row[2]] == []
finally:
_run(database.close())
@pytest.mark.parametrize("bad_kind", ["", "HOME", "Away", "present", "absent", 1])
def test_presence_patterns_kind_check_enforced(tmp_path, bad_kind) -> None:
database = _open_db(tmp_path / "presence-patterns-kind.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_pattern(database, child_id=1, kind=bad_kind)
finally:
_run(database.close())
@pytest.mark.parametrize("kind", ["home", "away"])
def test_presence_patterns_both_kinds_accepted(tmp_path, kind) -> None:
database = _open_db(tmp_path / f"presence-patterns-kind-{kind}.db")
try:
_apply(database)
_child(database)
pattern_id = _insert_pattern(database, child_id=1, kind=kind)
row = _run(
database.fetch_one(
"SELECT kind FROM presence_patterns WHERE id = ?", (pattern_id,)
)
)
assert row == (kind,)
finally:
_run(database.close())
@pytest.mark.parametrize("column", ["name", "kind"])
def test_presence_patterns_name_and_kind_not_null(tmp_path, column) -> None:
database = _open_db(tmp_path / f"presence-patterns-null-{column}.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="NOT NULL"):
_insert_pattern(database, child_id=1, **{column: None})
finally:
_run(database.close())
def test_presence_schedules_absent_from_current_schema_ddl() -> None:
assert not any(
"presence_schedules" in sql for sql in SCHEMA_V1_STATEMENTS
)
def test_presence_patterns_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-bad-child.db")
try:
_apply(database)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_schedule(database, child_id=999)
_insert_pattern(database, child_id=999)
finally:
_run(database.close())
@pytest.mark.parametrize("bad_cycle", [0, 5, "abc", 1.5])
def test_presence_schedules_cycle_length_out_of_range_or_type_fails(
def test_presence_patterns_cycle_length_out_of_range_or_type_fails(
tmp_path, bad_cycle
) -> None:
database = _open_db(tmp_path / f"cycle-{type(bad_cycle).__name__}.db")
@@ -1466,7 +1550,7 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails(
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_schedule(database, child_id=1, cycle_length_weeks=bad_cycle)
_insert_pattern(database, child_id=1, cycle_length_weeks=bad_cycle)
finally:
_run(database.close())
@@ -1487,18 +1571,18 @@ def test_presence_schedules_cycle_length_out_of_range_or_type_fails(
(4, "0,1|0,1|0,1|0,1"),
],
)
def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-valid.db")
def test_presence_patterns_valid_patterns_succeed(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-patterns-patterns-valid.db")
try:
_apply(database)
_child(database)
schedule_id = _insert_schedule(
pattern_id = _insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
)
row = _run(
database.fetch_one(
"SELECT pattern FROM presence_schedules WHERE id = ?",
(schedule_id,),
"SELECT pattern FROM presence_patterns WHERE id = ?",
(pattern_id,),
)
)
assert row == (pattern,)
@@ -1529,32 +1613,32 @@ def test_presence_schedules_valid_patterns_succeed(tmp_path, cycle, pattern) ->
(1, "0,1,2,3,4,5,6,0"), # more than 7 elements
],
)
def test_presence_schedules_invalid_patterns_fail(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-schedules-patterns-invalid.db")
def test_presence_patterns_invalid_patterns_fail(tmp_path, cycle, pattern) -> None:
database = _open_db(tmp_path / "presence-patterns-patterns-invalid.db")
try:
_apply(database)
_child(database)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_schedule(
_insert_pattern(
database, child_id=1, cycle_length_weeks=cycle, pattern=pattern
)
finally:
_run(database.close())
def test_presence_schedules_cycle_length_bounds_pass(tmp_path) -> None:
database = _open_db(tmp_path / "presence-schedules-cycle-bounds.db")
def test_presence_patterns_cycle_length_bounds_pass(tmp_path) -> None:
database = _open_db(tmp_path / "presence-patterns-cycle-bounds.db")
try:
_apply(database)
_child(database)
_insert_schedule(database, child_id=1, cycle_length_weeks=1, pattern="0")
_insert_pattern(database, child_id=1, cycle_length_weeks=1, pattern="0")
_run(
database.execute(
"INSERT INTO children (display_name, created_at) VALUES (?, ?)",
("Bo", "2026-09-13T00:00:00+00:00"),
)
)
_insert_schedule(database, child_id=2, cycle_length_weeks=4, pattern="|||")
_insert_pattern(database, child_id=2, cycle_length_weeks=4, pattern="|||")
finally:
_run(database.close())
@@ -1732,11 +1816,11 @@ def test_applying_presence_ddl_twice_keeps_rows(tmp_path) -> None:
try:
_apply(database)
_child(database)
_insert_schedule(database, child_id=1)
_insert_pattern(database, child_id=1)
_insert_override(database, child_id=1)
_apply(database)
counts = []
for table in ("presence_schedules", "presence_overrides"):
for table in ("presence_patterns", "presence_overrides"):
row = _run(database.fetch_one(f"SELECT COUNT(*) FROM {table}"))
counts.append(row[0])
assert counts == [1, 1]