diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index 907ad8d..9dabf0d 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -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). diff --git a/tests/test_presence.py b/tests/test_presence.py index b38727e..284eadf 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -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