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>
166 lines
6.1 KiB
Python
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"
|