From 1bb061747122c5da0bc193dc7578ff8e53cfdff3 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 18:19:53 -0400 Subject: [PATCH] Next-N-present-days preview (Feature 05, task 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresenceEngine.next_present_dates(child_id, start_date, count): walks forward from the INCLUSIVE start date through is_present, so overrides and the pattern are honoured identically — this is the preview the admin Schedule tab renders before a parent saves a custody pattern. Count must be a positive int; the scan is capped at two years (MAX_PREVIEW_SCAN_DAYS) and an always-absent child raises a clear ValueError instead of looping forever. Tests: known two-week pattern (straight five-day week, mid-week start skipping the absent week), inclusive start, override blocking the preview start, schedule-free child, input validation, always-absent error. Suite: 1002 passed. --- custom_components/nestquest/presence.py | 46 ++++++++++ tests/test_presence.py | 117 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py index ff233b4..465c105 100644 --- a/custom_components/nestquest/presence.py +++ b/custom_components/nestquest/presence.py @@ -319,6 +319,13 @@ def cycle_week_index( return (days // 7) % schedule.cycle_length_weeks +#: Search cap for :meth:`PresenceEngine.next_present_dates`: two full +#: years of daily checks. A child absent every day (an all-absent +#: pattern with a blocking override) would otherwise loop forever; the +#: cap bounds the scan and surfaces the situation as an error instead. +MAX_PREVIEW_SCAN_DAYS = 366 * 2 + + class PresenceEngine: """Answers "is this child at this house on this date?". @@ -424,6 +431,45 @@ class PresenceEngine: week = cycle_week_index(schedule, target) return target.weekday() in schedule.pattern[week] + def next_present_dates( + self, + child_id: object, + start_date: object, + count: int, + ) -> list[datetime.date]: + """Return the next ``count`` dates the child is present. + + Walks forward from ``start_date`` (INCLUSIVE — a parent + previewing "starting Monday" expects Monday itself when the + child is present) checking :meth:`is_present` for every day, + so overrides and the pattern are honoured identically. The + admin Schedule tab renders this preview so a parent can confirm + a custody pattern is right before saving it. + + A child absent every scanned day raises ValueError after the + two-year search cap — an always-absent child would otherwise + loop forever. ``count`` must be a positive integer. + """ + _validate_child_id(child_id) + start = _parse_date(start_date, "start_date") + if isinstance(count, bool) or not isinstance(count, int): + raise ValueError(f"count must be an integer, got {count!r}") + if count < 1: + raise ValueError(f"count must be at least 1, got {count}") + found: list[datetime.date] = [] + day = start + for _ in range(MAX_PREVIEW_SCAN_DAYS): + if self.is_present(child_id, day): + found.append(day) + if len(found) == count: + return found + day += datetime.timedelta(days=1) + raise ValueError( + f"child {child_id} is not present on any of the " + f"{MAX_PREVIEW_SCAN_DAYS} days from {start.isoformat()}; " + f"{count} present day(s) cannot be previewed" + ) + def __setattr__(self, name: object, value: object) -> None: raise AttributeError( "PresenceEngine is an immutable snapshot; build a new engine " diff --git a/tests/test_presence.py b/tests/test_presence.py index ffcb7c7..6ff8d06 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -599,3 +599,120 @@ def test_engine_override_lists_are_frozen() -> None: engine._overrides[1].append(entry) with pytest.raises(AttributeError, match="immutable snapshot"): engine._overrides = {} + + +# --------------------------------------------------------------------------- +# next_present_dates: the parent's pattern preview +# --------------------------------------------------------------------------- + + +def _preview_engine() -> PresenceEngine: + """Declan: home all of week 0, away all of week 1 (anchor + 2026-01-05, a Monday); an away override Tue Jan 13 (his away week) + would matter only for the override test.""" + return PresenceEngine( + { + 1: PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + } + ) + + +def test_preview_returns_the_next_present_days_of_a_known_pattern() -> None: + engine = _preview_engine() + # Starting Monday Jan 5 (present week 0): five straight days. + preview = engine.next_present_dates(1, "2026-01-05", 5) + assert preview == [ + datetime.date(2026, 1, 5), + datetime.date(2026, 1, 6), + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + # Starting mid-week: the rest of week 0 first, then the absent + # week 1 is skipped and the next present week picks up. + preview = engine.next_present_dates(1, "2026-01-07", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + preview = engine.next_present_dates(1, "2026-01-07", 6) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + datetime.date(2026, 1, 14), + datetime.date(2026, 1, 15), + datetime.date(2026, 1, 16), + ] + + +def test_preview_start_date_is_inclusive() -> None: + engine = _preview_engine() + # Jan 12 (Monday of the away week) is absent: the first present day + # is Jan 19 — the start date itself when present. + preview = engine.next_present_dates(1, "2026-01-19", 1) + assert preview == [datetime.date(2026, 1, 19)] + + +def test_preview_honours_overrides() -> None: + schedule = PresenceSchedule( + 1, 2, datetime.date(2026, 1, 5), + {0: {0, 1, 2, 3, 4, 5, 6}, 1: set()}, + ) + engine = PresenceEngine( + {1: schedule}, + overrides={ + 1: [ + PresenceOverride( + 1, "2026-01-05", "2026-01-06", False, note="holiday" + ) + ] + }, + ) + # Jan 5-6 blocked by the override: the preview starts Jan 7. + preview = engine.next_present_dates(1, "2026-01-04", 3) + assert preview == [ + datetime.date(2026, 1, 7), + datetime.date(2026, 1, 8), + datetime.date(2026, 1, 9), + ] + + +def test_preview_schedule_free_child_returns_consecutive_days() -> None: + engine = PresenceEngine({}) + preview = engine.next_present_dates(3, "2026-03-01", 3) + assert preview == [ + datetime.date(2026, 3, 1), + datetime.date(2026, 3, 2), + datetime.date(2026, 3, 3), + ] + + +def test_preview_rejects_bad_inputs() -> None: + engine = _preview_engine() + with pytest.raises(ValueError, match="count must be an integer"): + engine.next_present_dates(1, "2026-01-05", "3") + with pytest.raises(ValueError, match="count must be at least 1"): + engine.next_present_dates(1, "2026-01-05", 0) + with pytest.raises(ValueError, match="child_id must be an integer"): + engine.next_present_dates(True, "2026-01-05", 1) + with pytest.raises(ValueError, match="plain calendar date"): + engine.next_present_dates(1, datetime.datetime(2026, 1, 5), 1) + + +def test_preview_always_absent_child_raises_not_loops() -> None: + """A child absent every scanned day must surface as an error, not + hang the admin preview.""" + engine = PresenceEngine( + { + 1: PresenceSchedule( + 1, 1, datetime.date(2026, 1, 5), {0: set()} + ) + } + ) + with pytest.raises(ValueError, match="not present on any"): + engine.next_present_dates(1, "2026-01-05", 2)