From 540f59a3534deb935b052aba8a4fc444a4c98eb0 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 17:57:02 -0400 Subject: [PATCH] PresenceEngine with is_present and the always-present default (Feature 05, task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine is an immutable, database-free snapshot of the children's schedules answering is_present(child_id, date): a child with NO schedule entry is present every day (the household's always-present sibling); a scheduled child is present exactly when the date's weekday is in the pattern for that date's cycle week (anchor-date arithmetic via cycle_week_index — never ISO parity). Construction validates the snapshot shape (dict keyed by the schedule's own child_id, values are PresenceSchedules) and exposes it read-only (MappingProxyType + __setattr__ refusal). is_present rejects non-int child ids and non-plain dates. Tests: the three-child fixture (two complementary rotation children, one schedule-free) asserted for every day of a four-week span plus spot-checked swap days; weekend/weekday-set behaviour across weeks; construction/input rejection matrix; engine immutability. Suite: 985 passed. --- custom_components/nestquest/presence.py | 54 +++++++++++++++ tests/test_presence.py | 91 +++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 5c65590..797081c 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -317,3 +317,57 @@ def cycle_week_index( target = _parse_date(target_date, "target_date") days = (target - schedule.anchor_date).days return (days // 7) % schedule.cycle_length_weeks + + +class PresenceEngine: + """Answers "is this child at this house on this date?". + + Pure and side-effect-free: built from an immutable snapshot of the + children's schedules (``child_id -> PresenceSchedule``), it never + touches the database — callers build a snapshot from the DAO layer + and pass it in. A child with NO schedule entry is present every + day (the third household child's case); a child WITH a schedule is + present exactly when the date's weekday is in that schedule's + pattern for the date's cycle week index (anchor-date arithmetic — + :func:`cycle_week_index`, never ISO week parity). + """ + + def __init__(self, schedules: dict[int, PresenceSchedule]) -> None: + if not isinstance(schedules, dict): + raise ValueError( + f"schedules must map child_id to PresenceSchedule, got " + f"{schedules!r}" + ) + for child_id, schedule in schedules.items(): + if not isinstance(schedule, PresenceSchedule): + raise ValueError( + f"schedules[{child_id!r}] must be a PresenceSchedule, " + f"got {schedule!r}" + ) + if schedule.child_id != child_id: + raise ValueError( + f"schedules is keyed {child_id!r} but holds the " + f"schedule of child {schedule.child_id}" + ) + # Read-only snapshot: reassignment is refused and no mutator + # exists, so a built engine answers consistently for its + # lifetime. + object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules))) + + def is_present(self, child_id: object, date: object) -> bool: + """Return whether ``child_id`` is present on ``date``.""" + if isinstance(child_id, bool) or not isinstance(child_id, int): + raise ValueError(f"child_id must be an integer, got {child_id!r}") + target = _parse_date(date, "date") + schedule = self._schedules.get(child_id) + if schedule is None: + # No schedule at all: present every day (household default). + return True + week = cycle_week_index(schedule, target) + return target.weekday() in schedule.pattern[week] + + def __setattr__(self, name: object, value: object) -> None: + raise AttributeError( + "PresenceEngine is an immutable snapshot; build a new engine " + "instead of mutating this one" + ) diff --git a/tests/test_presence.py b/tests/test_presence.py index 96fe211..fd2570e 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -7,6 +7,7 @@ import pytest from custom_components.nestquest.presence import ( MAX_CYCLE_LENGTH_WEEKS, + PresenceEngine, PresenceOverride, PresenceSchedule, cycle_week_index, @@ -371,3 +372,93 @@ def test_module_uses_no_iso_week_functions() -> None: ).read_text() assert "isocalendar" not in source assert "isoweek" not in source + + +# --------------------------------------------------------------------------- +# PresenceEngine: is_present with the always-present default +# --------------------------------------------------------------------------- + + +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).""" + anchor = datetime.date(2026, 1, 5) # Monday, week 0 + declan = PresenceSchedule( + 1, 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}} + ) + return PresenceEngine({1: declan, 2: jordyn}) + + +def test_engine_child_without_schedule_is_always_present() -> None: + engine = _three_child_engine() + # Chloe has no schedule: present every day, including weekends. + for offset in range(0, 28, 3): + day = datetime.date(2026, 1, 5) + datetime.timedelta(days=offset) + assert engine.is_present(3, day) is True, f"offset {offset}" + + +def test_engine_alternating_children_are_complementary_for_four_weeks() -> None: + """Every day of a four-week span: the two rotation children swap + presence exactly per the anchor pattern — when one is home the + other is away.""" + engine = _three_child_engine() + anchor = datetime.date(2026, 1, 5) + for offset in range(28): + day = anchor + datetime.timedelta(days=offset) + d = engine.is_present(1, day) + j = engine.is_present(2, day) + assert d == (not j), f"offset {offset}: {d}/{j} not complementary" + expected_week = ((day - anchor).days // 7) % 2 + expected_present = day.weekday() in {0, 1, 2, 3, 4, 5, 6} + assert engine.is_present(1, day) == (expected_week == 0), ( + f"offset {offset}: Declan week {expected_week}" + ) + # Spot-check the swap days. + assert engine.is_present(1, anchor) is True + assert engine.is_present(2, anchor) is False + assert engine.is_present(1, anchor + datetime.timedelta(days=7)) is False + assert engine.is_present(2, anchor + datetime.timedelta(days=7)) is True + + +def test_engine_weekend_absence_respects_the_pattern() -> None: + """A weekday-set pattern excludes days outside the set even in the + child's present week.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), {0: {1, 3}, 1: {2, 4}} + ) + } + ) + # 2026-01-06 (Tue) is week 0, weekday 1 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 6)) is True + # 2026-01-07 (Wed) is week 0, weekday 2 -> absent. + assert engine.is_present(1, datetime.date(2026, 1, 7)) is False + # 2026-01-14 (Wed) is week 1, weekday 2 -> present. + assert engine.is_present(1, datetime.date(2026, 1, 14)) is True + + +def test_engine_rejects_bad_construction_and_inputs() -> None: + schedule = PresenceSchedule(1, 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}) + engine = PresenceEngine({1: schedule}) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.is_present(True, ANCHOR) + with pytest.raises(ValueError, match="plain calendar date"): + engine.is_present(1, datetime.datetime(2026, 1, 5)) + + +def test_engine_is_immutable() -> None: + engine = _three_child_engine() + with pytest.raises(AttributeError, match="immutable snapshot"): + engine._schedules = {} + with pytest.raises(AttributeError, match="immutable snapshot"): + engine.something_new = 1