Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7911f66f6 | ||
|
|
87251eeec5 |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user