Files
NestQuest/tests/test_presence_patterns.py
T

166 lines
6.1 KiB
Python
Raw Normal View History

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