Files
NestQuest/tests/test_presence_patterns.py
T
overseerandClaude Opus 5.5 ef6940d937 Replace presence schedules with multiple presence patterns per child (schema 9)
A child now carries zero or more home/away presence patterns instead of
at most one schedule. Migration 9 creates presence_patterns, copies every
presence_schedules row across as a 'home' pattern named "Home schedule"
(cycle, anchor and pattern verbatim), and drops the old table in one
transaction. Migration 1 keeps creating the retired table from a frozen
copy of its DDL; schema.py no longer declares it.

PresenceEngine resolves override -> any covering away pattern (absent)
-> any covering home pattern (present) -> otherwise absent when the child
has a home pattern, else present. The home-pattern default keeps every
migrated child's presence unchanged.

The admin plane's one-per-child presence-schedule routes are replaced by
pattern CRUD: GET/POST /children/{child_id}/presence-patterns and
PATCH/DELETE /presence-patterns/{pattern_id}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:31:34 -04:00

166 lines
6.1 KiB
Python

"""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"