PresenceEngine with is_present and the always-present default (Feature 05, task 3)
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.
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user