From b7911f66f64ac9a38d2293a86ae2165b482a5f7c Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 10:59:31 -0400 Subject: [PATCH] fix: dispatch shape before window checks (unimplemented shapes always raise); interval-7 weekday-alignment and leap-day interval-2 tests --- custom_components/nestquest/recurrence.py | 23 +++++--- tests/test_recurrence_daily.py | 72 ++++++++++++++++++++++- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index d70512d..d0eba90 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -425,16 +425,18 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: are plain calendar dates (Feature 04 guardrail); the caller decides the timezone that produced them. """ + # Dispatch FIRST: an unimplemented shape must raise loudly even for + # dates outside the window, never silently evaluate to False. + shape = rule.rule_type + if shape is not RuleType.DAILY: + raise NotImplementedError( + f"occurs_on for {shape} lands with its own engine task " + "(Features 04 task sequence)" + ) if not _within_window(rule, target): return False - shape = rule.rule_type - if shape is RuleType.DAILY: - offset = (target - _parse_date(rule.start_date)).days - return offset % rule.interval == 0 - raise NotImplementedError( - f"occurs_on for {shape} lands with its own engine task " - "(Features 04 task sequence)" - ) + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 def occurrences_between( @@ -446,6 +448,11 @@ def occurrences_between( inverted bounds; a window entirely outside [rule.start_date, rule.end_date] yields an empty list. """ + if rule.rule_type is not RuleType.DAILY: + raise NotImplementedError( + f"occurrences_between for {rule.rule_type} lands with its own " + "engine task (Features 04 task sequence)" + ) _validate_date(start, "start") _validate_date(end, "end") start_date = _parse_date(start) diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index d3b1b35..a4ab4e0 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -220,4 +220,74 @@ def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None: rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" ) with pytest.raises(NotImplementedError): - occurs_on(rule, _d("2026-09-07")) \ No newline at end of file + occurs_on(rule, _d("2026-09-07")) + +def test_interval_7_preserves_weekday_alignment_across_months() -> None: + """Weekly-by-interval semantics: every firing lands on the SAME + weekday as start_date, no matter how many month edges intervene.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01" + ) + # 2026-09-01 is a Tuesday; every firing must be a Tuesday too. + assert _d("2026-09-01").weekday() == 1 + for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"): + assert occurs_on(rule, _d(day)) is True, day + assert _d(day).weekday() == 1, f"{day} must be a Tuesday" + # The day after a firing is never a firing. + assert occurs_on(rule, _d("2026-10-07")) is False + + +def test_interval_2_across_leap_day() -> None: + """Interval > 1 arithmetic across a leap day: the extra day shifts + subsequent offsets by one, and the mod arithmetic reflects it.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27" + ) + assert occurs_on(rule, _d("2028-02-27")) is True # 0 + assert occurs_on(rule, _d("2028-02-28")) is False # 1 + assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day) + assert occurs_on(rule, _d("2028-03-01")) is False # 3 + assert occurs_on(rule, _d("2028-03-02")) is True # 4 + + +def test_occurrences_between_leap_day_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27" + ) + result = occurrences_between(rule, "2028-02-27", "2028-03-03") + assert result == ["2028-02-27", "2028-02-29", "2028-03-02"] + + +def test_occurrences_between_year_boundary_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30" + ) + result = occurrences_between(rule, "2026-12-30", "2027-01-04") + # Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are + # Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets: + assert result == ["2026-12-30", "2027-01-01", "2027-01-03"] + + +def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None: + """An unimplemented shape must raise whether the date is inside or + outside the window — silent False would hide a missing engine.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, + start_date="2026-09-01", end_date="2026-09-10", + ) + # Before start, inside window, after end: all must raise. + for day in ("2026-08-31", "2026-09-07", "2026-09-20"): + with pytest.raises(NotImplementedError): + occurs_on(rule, _d(day)) + + +def test_occurrences_between_unimplemented_shape_raises_even_disjoint( + tmp_path, +) -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + ) + # A range entirely before the rule's start still raises: the shape + # is unimplemented, and silence would hide it. + with pytest.raises(NotImplementedError): + occurrences_between(rule, "2026-08-01", "2026-08-10")