Core, API and Admin PWA: multiple presence patterns per child (schema 9) (#231)
This commit was merged in pull request #231.
This commit is contained in:
+584
-144
@@ -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"]]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user