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:
@@ -332,7 +332,11 @@ class PresenceEngine:
|
|||||||
:func:`cycle_week_index`, never ISO week parity).
|
: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):
|
if not isinstance(schedules, dict):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"schedules must map child_id to PresenceSchedule, got "
|
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"schedules is keyed {child_id!r} but holds the "
|
||||||
f"schedule of child {schedule.child_id}"
|
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
|
# Read-only snapshot: reassignment is refused and no mutator
|
||||||
# exists, so a built engine answers consistently for its
|
# exists, so a built engine answers consistently for its
|
||||||
# lifetime.
|
# lifetime.
|
||||||
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
object.__setattr__(self, "_schedules", MappingProxyType(dict(schedules)))
|
||||||
|
object.__setattr__(
|
||||||
|
self, "_overrides", MappingProxyType(dict(overrides))
|
||||||
|
)
|
||||||
|
|
||||||
def is_present(self, child_id: object, date: object) -> bool:
|
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):
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
target = _parse_date(date, "date")
|
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)
|
schedule = self._schedules.get(child_id)
|
||||||
if schedule is None:
|
if schedule is None:
|
||||||
# No schedule at all: present every day (household default).
|
# No schedule at all: present every day (household default).
|
||||||
|
|||||||
@@ -476,3 +476,101 @@ def test_engine_is_immutable() -> None:
|
|||||||
engine._schedules = {}
|
engine._schedules = {}
|
||||||
with pytest.raises(AttributeError, match="immutable snapshot"):
|
with pytest.raises(AttributeError, match="immutable snapshot"):
|
||||||
engine.something_new = 1
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user