Overrides take precedence over the pattern (Feature 05, task 5)

PresenceEngine gains an overrides snapshot (child_id -> list of
PresenceOverride): is_present checks covering overrides BEFORE the
pattern and returns the override's flag as the answer — an absent
override on a normally-present day returns False, a present one on a
normally-absent day returns True, and an override on a child with no
schedule still applies (holidays work for the always-present sibling).
Snapshot validation mirrors the schedules': dict of lists keyed by the
override's own child id, exposed read-only. The DAO layer guarantees
no overlapping overrides per child; a hand-built snapshot that carries
them resolves deterministically to the LATEST start date (documented).
Tests: all three precedence cases, span edges (outside days stay on
the pattern), snapshot rejection matrix, deterministic overlap
tie-break. Suite: 995 passed.
This commit is contained in:
Joshua King
2026-09-15 18:12:16 -04:00
parent 94f54523f9
commit 8f04d93363
2 changed files with 147 additions and 2 deletions
+49 -2
View File
@@ -332,7 +332,11 @@ class PresenceEngine:
:func:`cycle_week_index`, never ISO week parity).
"""
def __init__(self, schedules: dict[int, PresenceSchedule]) -> None:
def __init__(
self,
schedules: dict[int, PresenceSchedule],
overrides: dict[int, list[PresenceOverride]] | None = None,
) -> None:
if not isinstance(schedules, dict):
raise ValueError(
f"schedules must map child_id to PresenceSchedule, got "
@@ -353,16 +357,59 @@ class PresenceEngine:
f"schedules is keyed {child_id!r} but holds the "
f"schedule of child {schedule.child_id}"
)
if overrides is None:
overrides = {}
if not isinstance(overrides, dict):
raise ValueError(
"overrides must map child_id to a list of "
f"PresenceOverride, got {overrides!r}"
)
for child_id, entries in overrides.items():
_validate_child_id(child_id)
if not isinstance(entries, list):
raise ValueError(
f"overrides[{child_id!r}] must be a list of "
f"PresenceOverride, got {entries!r}"
)
for entry in entries:
if not isinstance(entry, PresenceOverride):
raise ValueError(
f"overrides[{child_id!r}] entries must be "
f"PresenceOverride, got {entry!r}"
)
if entry.child_id != child_id:
raise ValueError(
f"overrides is keyed {child_id!r} but holds an "
f"override of child {entry.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)))
object.__setattr__(
self, "_overrides", MappingProxyType(dict(overrides))
)
def is_present(self, child_id: object, date: object) -> bool:
"""Return whether ``child_id`` is present on ``date``."""
"""Return whether ``child_id`` is present on ``date``.
Overrides take precedence over the repeating pattern: when an
override covers the date, its flag IS the answer — including
for a child with no schedule at all. The DAO layer guarantees
a child's overrides never overlap; if a snapshot still carries
two covering the same date, the one with the LATEST start date
wins (deterministic, and matches "the most specific swap wins").
"""
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")
entries = self._overrides.get(child_id) or []
covering = [
entry for entry in entries if entry.covers(target)
]
if covering:
winner = max(covering, key=lambda entry: entry.start_date)
return winner.is_present
schedule = self._schedules.get(child_id)
if schedule is None:
# No schedule at all: present every day (household default).
+98
View File
@@ -476,3 +476,101 @@ def test_engine_is_immutable() -> None:
engine._schedules = {}
with pytest.raises(AttributeError, match="immutable snapshot"):
engine.something_new = 1
# ---------------------------------------------------------------------------
# Overrides take precedence over the pattern
# ---------------------------------------------------------------------------
def _engine_with_overrides(*overrides_for_declan) -> PresenceEngine:
anchor = datetime.date(2026, 1, 5) # Monday
return PresenceEngine(
{
1: PresenceSchedule(
1, 2, anchor, {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}
)
},
overrides={1: list(overrides_for_declan)} if overrides_for_declan else {},
)
def test_absent_override_on_normally_present_day_wins() -> None:
# 2026-01-06 is Tuesday of Declan's week 0: normally present.
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-06", "2026-01-06", False, note="away")
)
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
# The pattern resumes outside the override.
assert engine.is_present(1, datetime.date(2026, 1, 7)) is True
def test_present_override_on_normally_absent_day_wins() -> None:
# 2026-01-13 is in Declan's week 1 (away all week); a present
# override on the Tuesday of week 1 brings him home for that day.
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-13", "2026-01-13", True, note="trade")
)
assert engine.is_present(1, datetime.date(2026, 1, 13)) is True
assert engine.is_present(1, datetime.date(2026, 1, 14)) is False
def test_override_applies_without_a_schedule() -> None:
"""An override on a schedule-free child still applies: holidays and
swaps must work for the always-present sibling too."""
engine = PresenceEngine(
{},
overrides={
3: [
PresenceOverride(
3, "2026-07-20", "2026-07-24", False, note="holiday"
)
]
},
)
assert engine.is_present(3, datetime.date(2026, 7, 22)) is False
assert engine.is_present(3, datetime.date(2026, 7, 19)) is True
assert engine.is_present(3, datetime.date(2026, 7, 25)) is True
def test_override_range_beats_pattern_for_its_span() -> None:
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-13", "2026-01-17", True, note="traded week")
)
for offset in range(12, 17):
day = datetime.date(2026, 1, 1) + datetime.timedelta(days=offset)
assert engine.is_present(1, day) is True, f"{day}"
# Days outside the span stay on the pattern (week 1: absent).
assert engine.is_present(1, datetime.date(2026, 1, 12)) is False
assert engine.is_present(1, datetime.date(2026, 1, 18)) is False
def test_engine_override_snapshot_validation() -> None:
schedule = PresenceSchedule(1, 1, ANCHOR, {0: {0}})
entry = PresenceOverride(1, "2026-07-20", "2026-07-20", True)
with pytest.raises(ValueError, match="must map child_id"):
PresenceEngine({1: schedule}, overrides=[entry])
with pytest.raises(ValueError, match="must be a list"):
PresenceEngine({1: schedule}, overrides={1: entry})
with pytest.raises(ValueError, match="must be"):
PresenceEngine({1: schedule}, overrides={1: ["nope"]})
with pytest.raises(ValueError, match="holds an override"):
PresenceEngine({1: schedule}, overrides={2: [entry]})
with pytest.raises(ValueError, match="child_id must be an integer"):
PresenceEngine({1: schedule}, overrides={True: [entry]})
def test_engine_overlapping_snapshot_uses_latest_start() -> None:
"""The DAO layer refuses overlapping overrides per child; if a
snapshot carries them anyway (hand-built), the latest start date
wins deterministically."""
engine = _engine_with_overrides(
PresenceOverride(1, "2026-01-06", "2026-01-10", False),
PresenceOverride(1, "2026-01-08", "2026-01-12", True),
)
# Jan 6-7: only the earlier override covers -> absent.
assert engine.is_present(1, datetime.date(2026, 1, 6)) is False
# Jan 8-10: both cover; the LATER start (present) wins.
assert engine.is_present(1, datetime.date(2026, 1, 9)) is True
# Jan 11-12: only the later override covers -> present.
assert engine.is_present(1, datetime.date(2026, 1, 11)) is True