diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index d0eba90..7c71458 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -428,15 +428,28 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: # 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: + if shape not in (RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS): 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 - offset = (target - _parse_date(rule.start_date)).days - return offset % rule.interval == 0 + if shape is RuleType.DAILY: + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 + # WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never + # ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO + # parity on January 1st; anchor arithmetic does not). + anchor = _parse_date(rule.start_date) + if shape is RuleType.CUSTOM_DAYS: + interval = 1 + else: + interval = rule.interval + week_offset = (target - anchor).days // 7 + if week_offset % interval != 0: + return False + return target.weekday() in rule.weekday_set def occurrences_between( @@ -448,7 +461,9 @@ 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: + if rule.rule_type not in ( + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS + ): raise NotImplementedError( f"occurrences_between for {rule.rule_type} lands with its own " "engine task (Features 04 task sequence)" diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index a4ab4e0..ed8fada 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -214,13 +214,14 @@ def test_occurrences_between_year_boundary() -> None: def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None: - """Weekly and beyond land with their own engine tasks; a loud + """Monthly/yearly land with their own engine tasks; a loud NotImplementedError beats a silent wrong answer.""" rule = ScheduleRule( - rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + start_date="2026-09-01", ) with pytest.raises(NotImplementedError): - occurs_on(rule, _d("2026-09-07")) + occurs_on(rule, _d("2026-09-15")) def test_interval_7_preserves_weekday_alignment_across_months() -> None: """Weekly-by-interval semantics: every firing lands on the SAME @@ -272,7 +273,7 @@ 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}, + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01", end_date="2026-09-10", ) # Before start, inside window, after end: all must raise. @@ -285,7 +286,8 @@ 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" + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + start_date="2026-09-01" ) # A range entirely before the rule's start still raises: the shape # is unimplemented, and silence would hide it. diff --git a/tests/test_recurrence_weekly.py b/tests/test_recurrence_weekly.py new file mode 100644 index 0000000..10ac59d --- /dev/null +++ b/tests/test_recurrence_weekly.py @@ -0,0 +1,269 @@ +"""Tests for weekly and custom day-set recurrence evaluation.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, + occurs_on, + occurrences_between, +) + + +def _d(date: str) -> datetime.date: + return datetime.date.fromisoformat(date) + + +# 2026-09-01 is a TUESDAY (weekday 1). +# A weekly Mon/Wed/Fri (0,2,4) rule starting Tue 2026-09-01, interval 1, +# fires: Sep 1 (Tue), Sep 2 (Wed), Sep 4 (Fri), then each following week +# on Mon/Wed/Fri: Sep 7, 9, 11, 14, 16, 18, 21, 23, 25, 28, 30, Oct 2... +# The 8-week hand-checked sequence below is built from this anchor. + + +def test_weekly_fires_exactly_on_set_weekdays() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", + ) + # Sep 2026: 1=Tue(1), 2=Wed(2), 4=Fri(4), 7=Mon(0), 8=Tue(1), 9=Wed, + # 10=Thu(3), 11=Fri(4) + assert occurs_on(rule, _d("2026-09-01")) is False # Tue NOT in set + assert occurs_on(rule, _d("2026-09-02")) is True # Wed in set + assert occurs_on(rule, _d("2026-09-03")) is False # Thu not in set + assert occurs_on(rule, _d("2026-09-04")) is True # Fri in set + assert occurs_on(rule, _d("2026-09-05")) is False # Sat + assert occurs_on(rule, _d("2026-09-06")) is False # Sun + assert occurs_on(rule, _d("2026-09-07")) is True # Mon + assert occurs_on(rule, _d("2026-09-08")) is False # Tue not in set + + +def test_weekly_start_date_itself_may_not_be_in_set() -> None: + """start_date anchors the WEEK count, not a firing day: a Tuesday + start for a Mon/Wed/Fri rule fires on Wed Sep 2, not Sep 1.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", + ) + assert occurs_on(rule, _d("2026-09-01")) is False # Tue=1, not in set + assert occurs_on(rule, _d("2026-09-02")) is True # Wed=2 + assert occurs_on(rule, _d("2026-09-04")) is True # Fri=4 + assert occurs_on(rule, _d("2026-09-07")) is True # Mon, next week + + +def test_weekly_never_fires_before_start() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={2}, # Wednesdays + start_date="2026-09-02", # a Wednesday + ) + # Wed Sep 2 is the anchor day and in-set: fires. Wed Aug 26 (one + # week BEFORE the anchor, in-set) must not fire: pre-window. + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-08-26")) is False # Wed, before start + assert occurs_on(rule, _d("2026-09-09")) is True # Wed, next week + + +def test_weekly_respects_end_date() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", end_date="2026-09-10", + ) + assert occurs_on(rule, _d("2026-09-09")) is True # Wed within end + assert occurs_on(rule, _d("2026-09-11")) is False # Fri after end + + +def test_weekly_interval_2_fires_on_alternate_weeks() -> None: + """Interval 2: fires on set weekdays only in even week-offsets.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", # a Monday + ) + # Week 0 (Sep 7-13): fires Mon Sep 7. + assert occurs_on(rule, _d("2026-09-07")) is True + # Week 1 (Sep 14-20): skipped. + assert occurs_on(rule, _d("2026-09-14")) is False + # Week 2 (Sep 21-27): fires Mon Sep 21. + assert occurs_on(rule, _d("2026-09-21")) is True + # Week 2 is not a firing week: Mon Sep 28 skipped. + assert occurs_on(rule, _d("2026-09-28")) is False + # Week 3: fires Mon Oct 5. + assert occurs_on(rule, _d("2026-10-05")) is True + + +def test_weekly_interval_3_every_third_week() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={3}, interval=3, + start_date="2026-09-03", # a Thursday + ) + assert occurs_on(rule, _d("2026-09-03")) is True # week 0 + assert occurs_on(rule, _d("2026-09-10")) is False # week 1 + assert occurs_on(rule, _d("2026-09-17")) is False # week 2 + assert occurs_on(rule, _d("2026-09-24")) is True # week 3 + assert occurs_on(rule, _d("2026-10-15")) is True # week 6 + + +def test_weekly_interval_2_never_fires_in_odd_weeks_across_months() -> None: + """The week offset is anchored to start_date across month edges.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={2}, interval=2, + start_date="2026-09-02", # a Wednesday + ) + # Week 0: Wed Sep 2 fires. Week 1: Wed Sep 9 skipped. + # Week 2: Wed Sep 16 fires. Week 3: Wed Sep 23 skipped. + # Week 4 (Oct): Wed Sep 30 fires (still week 4!). Week 5: Wed Oct 7 skipped. + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-09-09")) is False + assert occurs_on(rule, _d("2026-09-16")) is True + assert occurs_on(rule, _d("2026-09-23")) is False + assert occurs_on(rule, _d("2026-09-30")) is True + assert occurs_on(rule, _d("2026-10-07")) is False + assert occurs_on(rule, _d("2026-10-14")) is True + + +def test_custom_days_interval_1_behaves_like_weekly() -> None: + """CUSTOM_DAYS with interval 1 behaves identically to WEEKLY.""" + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5}, + start_date="2026-09-01", + ) + custom = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5}, + start_date="2026-09-01", + ) + # Whole month of September must match exactly. + for day in range(1, 31): + date = datetime.date(2026, 9, day) + assert occurs_on(weekly, date) == occurs_on(custom, date), date + + +def test_custom_days_never_fires_before_start(tmp_path) -> None: + rule = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={0}, + start_date="2026-09-07", # a Monday + ) + assert occurs_on(rule, _d("2026-09-06")) is False # Sun, before + assert occurs_on(rule, _d("2026-09-07")) is True # Mon, on + + +def test_hand_checked_8_week_sequence_weekly_mon_wed_fri() -> None: + """The done-condition's hand-checked sequence: 8 weeks of a + Mon/Wed/Fri rule anchored at Tue 2026-09-01, interval 1. + + Week 0 (Sep 1-6): Wed 2, Fri 4 (Mon 31 Aug is pre-start) + Week 1 (Sep 7-13): Mon 7, Wed 9, Fri 11 + Week 2 (Sep 14-20): Mon 14, Wed 16, Fri 18 + Week 3 (Sep 21-27): Mon 21, Wed 23, Fri 25 + Week 4 (Sep 28-Oct 4): Mon 28, Wed 30, Fri 2 (Oct) + Week 5 (Oct 5-11): Mon 5, Wed 7, Fri 9 + Week 6 (Oct 12-18): Mon 12, Wed 14, Fri 16 + Week 7 (Oct 19-25): Mon 19, Wed 21, Fri 23 + """ + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", end_date="2026-10-25", + ) + expected = [ + "2026-09-02", "2026-09-04", + "2026-09-07", "2026-09-09", "2026-09-11", + "2026-09-14", "2026-09-16", "2026-09-18", + "2026-09-21", "2026-09-23", "2026-09-25", + "2026-09-28", "2026-09-30", "2026-10-02", + "2026-10-05", "2026-10-07", "2026-10-09", + "2026-10-12", "2026-10-14", "2026-10-16", + "2026-10-19", "2026-10-21", "2026-10-23", + ] + actual = occurrences_between(rule, "2026-09-01", "2026-10-25") + assert actual == expected, ( + f"diff: {set(expected) ^ set(actual)}" + ) + + +def test_hand_checked_8_week_sequence_weekly_interval_2_mondays() -> None: + """8 weeks of a Monday rule at interval 2, anchored Mon 2026-09-07: + fires in weeks 0, 2, 4, 6 -> Sep 7, Sep 21, Oct 5, Oct 19.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", end_date="2026-10-31", + ) + actual = occurrences_between(rule, "2026-09-07", "2026-10-31") + assert actual == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"] + + +def test_custom_days_hand_checked_8_week_sequence() -> None: + """8 weeks of a Tue/Thu/Sun rule (1,3,6) anchored Mon 2026-09-07: + fires Tue 8, Thu 10, Sun 13, then weekly.""" + rule = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 6}, + start_date="2026-09-07", + ) + actual = occurrences_between(rule, "2026-09-07", "2026-10-27") + assert actual == [ + "2026-09-08", "2026-09-10", "2026-09-13", + "2026-09-15", "2026-09-17", "2026-09-20", + "2026-09-22", "2026-09-24", "2026-09-27", + "2026-09-29", "2026-10-01", "2026-10-04", + "2026-10-06", "2026-10-08", "2026-10-11", + "2026-10-13", "2026-10-15", "2026-10-18", + "2026-10-20", "2026-10-22", "2026-10-25", "2026-10-27", + ] + + +def test_weekly_survives_53_week_year_boundary() -> None: + """Anchor arithmetic never flips at Jan 1 (the ISO parity trap): + a weekly rule spanning New Year keeps its weekday pattern.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={3}, # Thursdays + start_date="2026-12-24", # a Thursday + ) + # Thursdays around New Year 2026->2027: Dec 24, 31, Jan 7, 14. + assert occurs_on(rule, _d("2026-12-24")) is True + assert occurs_on(rule, _d("2026-12-31")) is True + assert occurs_on(rule, _d("2027-01-07")) is True + assert occurs_on(rule, _d("2027-01-14")) is True + # Interleaved days are not Thursdays. + assert occurs_on(rule, _d("2027-01-04")) is False # Monday + assert occurs_on(rule, _d("2027-01-05")) is False # Tuesday + + +def test_weekly_interval_2_across_year_boundary() -> None: + """Interval weeks continue across Jan 1 with no parity flip.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={4}, interval=2, + start_date="2026-12-25", # a Friday + ) + # Weeks: Dec 25 (w0) fires, Jan 1 (w1) skipped, Jan 8 (w2) fires, + # Jan 15 (w3) skipped, Jan 22 (w4) fires. + assert occurs_on(rule, _d("2026-12-25")) is True + assert occurs_on(rule, _d("2027-01-01")) is False + assert occurs_on(rule, _d("2027-01-08")) is True + assert occurs_on(rule, _d("2027-01-15")) is False + assert occurs_on(rule, _d("2027-01-22")) is True + + +def test_occurrences_between_weekly_disjoint(tmp_path) -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={1}, + start_date="2026-09-01", end_date="2026-09-30", + ) + assert occurrences_between(rule, "2026-10-01", "2026-10-31") == [] + + +def test_occurrences_between_weekly_interval_2_span() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", end_date="2026-10-31", + ) + result = occurrences_between(rule, "2026-09-01", "2026-10-31") + assert result == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"] + + +def test_occurrences_between_rejects_inverted_bounds() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + ) + with pytest.raises(RuleValidationError, match="on or after"): + occurrences_between(rule, "2026-10-10", "2026-09-01") \ No newline at end of file