diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index ba7f605..d0eba90 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -387,3 +387,92 @@ class ScheduleRule: start_date=data.get("start_date", "1970-01-01"), end_date=data.get("end_date"), ) + + +# --------------------------------------------------------------------------- +# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule +# --------------------------------------------------------------------------- + + +def _parse_date(value: str) -> datetime.date: + """Parse a strict ISO date already validated by the model.""" + return datetime.datetime.strptime(value, _DATE_FORMAT).date() + + +def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int: + """Day offset from the rule's anchor date to ``target``. + + The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the + first day the rule CAN fire. A negative offset means the target + precedes the rule's window entirely. + """ + return (target - _parse_date(rule.start_date)).days + + +def _within_window(rule: ScheduleRule, target: datetime.date) -> bool: + """True when ``target`` is inside the rule's [start, end] window.""" + if target < _parse_date(rule.start_date): + return False + if rule.end_date is not None and target > _parse_date(rule.end_date): + return False + return True + + +def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: + """Return True when ``rule`` fires on ``target``. + + Pure function: no I/O, no timezone awareness, no mutation. Dates + 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 + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 + + +def occurrences_between( + rule: ScheduleRule, start: str, end: str +) -> list[str]: + """Return every date string in [start, end] the rule fires on. + + Inclusive on both ends. Raises RuleValidationError on malformed or + 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) + end_date = _parse_date(end) + if end_date < start_date: + raise RuleValidationError( + f"end {end!r} must be on or after start {start!r}" + ) + rule_start = _parse_date(rule.start_date) + if end_date < rule_start: + return [] + # Clamp the walk to the rule's own window and the queried range. + effective_start = max(start_date, rule_start) + effective_end = end_date + if rule.end_date is not None: + effective_end = min(effective_end, _parse_date(rule.end_date)) + results: list[str] = [] + cursor = effective_start + while cursor <= effective_end: + if occurs_on(rule, cursor): + results.append(cursor.isoformat()) + cursor += datetime.timedelta(days=1) + return results diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py new file mode 100644 index 0000000..a4ab4e0 --- /dev/null +++ b/tests/test_recurrence_daily.py @@ -0,0 +1,293 @@ +"""Tests for the daily recurrence evaluation (occurs_on/occurrences_between).""" +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) + + +def test_daily_fires_every_day_from_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert occurs_on(rule, _d("2026-09-01")) is True + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-09-30")) is True + + +def test_daily_never_fires_before_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert occurs_on(rule, _d("2026-08-31")) is False + + +def test_daily_respects_end_date() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-10", + ) + assert occurs_on(rule, _d("2026-09-10")) is True + assert occurs_on(rule, _d("2026-09-11")) is False + + +def test_daily_interval_2_fires_every_second_day() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + # Odd offsets fire (0, 2, 4...); odd offsets don't. + assert occurs_on(rule, _d("2026-09-01")) is True # offset 0 + assert occurs_on(rule, _d("2026-09-02")) is False # offset 1 + assert occurs_on(rule, _d("2026-09-03")) is True # offset 2 + assert occurs_on(rule, _d("2026-09-04")) is False # offset 3 + assert occurs_on(rule, _d("2026-09-05")) is True # offset 4 + + +def test_daily_interval_3_fires_every_third_day() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01" + ) + assert occurs_on(rule, _d("2026-09-01")) is True # 0 + assert occurs_on(rule, _d("2026-09-02")) is False # 1 + assert occurs_on(rule, _d("2026-09-03")) is False # 2 + assert occurs_on(rule, _d("2026-09-04")) is True # 3 + assert occurs_on(rule, _d("2026-09-07")) is True # 6 + + +def test_hand_checked_30_day_sequence_interval_1() -> None: + """The done-condition's hand-checked sequence: 30 days, all fire.""" + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + expected = [ + (2026, 9, day) for day in range(1, 31) + ] + for (year, month, day) in expected: + assert occurs_on(rule, datetime.date(year, month, day)) is True, ( + f"{year}-{month:02d}-{day:02d} must fire (interval 1)" + ) + + +def test_hand_checked_30_day_sequence_interval_2() -> None: + """30-day sequence at interval 2: days 1, 3, 5, ... (odd days).""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + for day in range(1, 31): + fired = occurs_on(rule, datetime.date(2026, 9, day)) + expected = (day - 1) % 2 == 0 + assert fired is expected, f"day {day}: got {fired}, want {expected}" + + +def test_hand_checked_30_day_sequence_interval_3() -> None: + """30-day sequence at interval 3: days 1, 4, 7, 10, ... 28.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01" + ) + fired_days = [ + day + for day in range(1, 31) + if occurs_on(rule, datetime.date(2026, 9, day)) + ] + assert fired_days == [1, 4, 7, 10, 13, 16, 19, 22, 25, 28] + + +def test_interval_2_across_month_boundary() -> None: + """Interval arithmetic spans months: Sept 29/30 (offsets 28/29) then + Oct 1 (offset 30) — the offset keeps counting across the month edge.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + assert occurs_on(rule, _d("2026-09-29")) is True # offset 28 + assert occurs_on(rule, _d("2026-09-30")) is False # offset 29 + assert occurs_on(rule, _d("2026-10-01")) is True # offset 30 + assert occurs_on(rule, _d("2026-10-02")) is False # offset 31 + + +def test_interval_2_across_year_boundary() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30" + ) + assert occurs_on(rule, _d("2026-12-30")) is True # 0 + assert occurs_on(rule, _d("2026-12-31")) is False # 1 + assert occurs_on(rule, _d("2027-01-01")) is True # 2 + + +# --------------------------------------------------------------------------- +# occurrences_between +# --------------------------------------------------------------------------- + + +def test_occurrences_between_daily_interval_1() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + result = occurrences_between(rule, "2026-09-05", "2026-09-08") + assert result == [ + "2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08", + ] + + +def test_occurrences_between_daily_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + result = occurrences_between(rule, "2026-09-01", "2026-09-10") + assert result == [ + "2026-09-01", "2026-09-03", "2026-09-05", "2026-09-07", "2026-09-09", + ] + + +def test_occurrences_between_clamps_to_rule_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-05") + result = occurrences_between(rule, "2026-09-01", "2026-09-08") + assert result == ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"] + + +def test_occurrences_between_clamps_to_rule_end() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-06", + ) + result = occurrences_between(rule, "2026-09-01", "2026-09-20") + assert result == [ + f"2026-09-{day:02d}" for day in range(1, 7) + ] + + +def test_occurrences_between_disjoint_range_is_empty() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-15", + ) + # The rule ends Sept 15: October is entirely outside its window. + assert occurrences_between(rule, "2026-10-01", "2026-10-05") == [] + assert occurrences_between(rule, "2026-08-01", "2026-08-05") == [] + + +def test_occurrences_between_inclusive_bounds() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + # Both boundary dates fire at interval 2 (offsets 0 and 6). + result = occurrences_between(rule, "2026-09-01", "2026-09-07") + assert result[0] == "2026-09-01" + assert result[-1] == "2026-09-07" + + +def test_occurrences_between_rejects_inverted_range() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + with pytest.raises(RuleValidationError, match="on or after"): + occurrences_between(rule, "2026-09-10", "2026-09-05") + + +def test_occurrences_between_rejects_malformed_dates() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + with pytest.raises(RuleValidationError, match="YYYY-MM-DD"): + occurrences_between(rule, "oops", "2026-09-05") + with pytest.raises(RuleValidationError, match="YYYY-MM-DD"): + occurrences_between(rule, "2026-09-05", "2026-9-5") + + +def test_occurrences_between_leap_day_fires() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-02-27") + # 2028 is a leap year: Feb 29 exists and is 2 days after Feb 27. + result = occurrences_between(rule, "2028-02-28", "2028-03-01") + assert result == ["2028-02-28", "2028-02-29", "2028-03-01"] + + +def test_occurrences_between_year_boundary() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-12-30") + result = occurrences_between(rule, "2026-12-30", "2027-01-02") + assert result == [ + "2026-12-30", "2026-12-31", "2027-01-01", "2027-01-02", + ] + + +# --------------------------------------------------------------------------- +# Purity: the engine raises on unknown shapes rather than guessing +# --------------------------------------------------------------------------- + + +def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None: + """Weekly and beyond 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" + ) + with pytest.raises(NotImplementedError): + 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")