Compare commits

..
9 changed files with 45 additions and 2076 deletions
+9 -137
View File
@@ -39,16 +39,11 @@ reach storage):
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
when present, on or after ``start_date``.
Month-end policy (implemented in :func:`_occurs_monthly_day` and
documented for Feature 04): in each interval-eligible month, a
MONTHLY_DAY rule for a day that does not exist in that month — the
31st in April, the 30th or 29th in February, February 29th in a
non-leap year — fires on the LAST day of the month instead of being
skipped. A monthly chore still happens in every eligible month,
pinned to the month's end when its nominal date is absent; exactly one
day per eligible month matches the rule (nominal day when it exists,
month end when it does not). The interval anchors on nominal months
elapsed from start_date's month, so clamping never shifts the cadence.
Month-end policy (documented for Feature 04): a MONTHLY_DAY rule for
the 31st does NOT fire in a 30-day month, and a YEARLY rule for
February 29th does NOT fire in a non-leap year — the engine skips
instead of clamping to month end, because clamping silently moves
"the 31st" to "the 30th" and double-fires tasks.
"""
from __future__ import annotations
@@ -270,9 +265,10 @@ class ScheduleRule:
"nth_weekday_weekday", "month")
elif shape is RuleType.MONTHLY_WEEKDAY:
forbidden_fields = ("weekday_set", "day_of_month", "month")
elif shape is RuleType.YEARLY:
else: # YEARLY
forbidden_fields = (
"weekday_set",
"day_of_month",
"nth_weekday",
"nth_weekday_weekday",
)
@@ -315,14 +311,6 @@ class ScheduleRule:
raise RuleValidationError(
f"month must be 1..12, got {self.month!r}"
)
if self.day_of_month is not None and not (
_is_plain_int(self.day_of_month)
and 1 <= self.day_of_month <= 31
):
raise RuleValidationError(
f"day_of_month must be 1..31 for yearly rules, got "
f"{self.day_of_month!r}"
)
def to_dict(self) -> dict:
"""Return the lossless dict form (model-side storage shape).
@@ -430,112 +418,6 @@ def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
return True
def _month_end(year: int, month: int) -> int:
"""The last calendar day of the given month (leap-aware)."""
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
"""MONTHLY_DAY evaluation with the documented month-end policy.
Policy: in each interval-eligible month, a rule for a day-of-month
that does not exist in that month (the 31st in April, the 30th in
February, the 29th of February in a non-leap year) fires on the
LAST day of that month instead of being skipped — the chore still
happens that month, pinned to the month's end when its nominal date
is absent.
The interval anchors on months ELAPSED from start_date's month: the
rule fires when the months between the anchor month and the target's
month form a multiple of the interval, keeping "every 2 months on
the 15th" aligned to the anchor month even when the firing lands on
a clamped month-end day.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def _nth_weekday_of_month(
year: int, month: int, weekday: int, nth: int
) -> int | None:
"""The day-of-month of the ``nth`` ``weekday``, or None when the
month has no such occurrence (``nth`` 5 beyond four occurrences).
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
end (last occurrence, whatever the total). Pure calendar math.
"""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
first_offset = (weekday - first.weekday()) % 7
day = 1 + first_offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _occurs_monthly_weekday(
rule: ScheduleRule, target: datetime.date
) -> bool:
"""MONTHLY_WEEKDAY evaluation.
Fires when the target is the ``nth`` occurrence of
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
month with fewer occurrences than ``nth`` (5 on a four-occurrence
month) does not fire — n 5 is accepted by the model but skips such
months, matching the done-condition. The interval anchors on
nominal months elapsed from start_date's month.
"""
anchor = _parse_date(rule.start_date)
months_elapsed = (
(target.year - anchor.year) * 12 + (target.month - anchor.month)
)
if months_elapsed % rule.interval != 0:
return False
if target.weekday() != rule.nth_weekday_weekday:
return False
nth_day = _nth_weekday_of_month(
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
)
return nth_day is not None and target.day == nth_day
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
"""YEARLY evaluation with the documented leap-day clamp policy.
Policy: in each interval-eligible year, the rule fires on
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
still happens every year, pinned to February's end when its
nominal date is absent) rather than silently vanishing. A YEARLY
rule with NO day_of_month fires on the first day of ``month``.
The interval anchors on years elapsed from start_date's year.
"""
anchor = _parse_date(rule.start_date)
years_elapsed = target.year - anchor.year
if years_elapsed % rule.interval != 0:
return False
if target.month != rule.month:
return False
if rule.day_of_month is None:
# Day-less yearly rules fire on the first of the month.
return target.day == 1
month_end = _month_end(target.year, target.month)
return target.day == min(rule.day_of_month, month_end)
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
"""Return True when ``rule`` fires on ``target``.
@@ -546,10 +428,7 @@ 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 not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
):
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)"
@@ -559,12 +438,6 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
if shape is RuleType.DAILY:
offset = (target - _parse_date(rule.start_date)).days
return offset % rule.interval == 0
if shape is RuleType.MONTHLY_DAY:
return _occurs_monthly_day(rule, target)
if shape is RuleType.MONTHLY_WEEKDAY:
return _occurs_monthly_weekday(rule, target)
if shape is RuleType.YEARLY:
return _occurs_yearly(rule, target)
# 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).
@@ -589,8 +462,7 @@ def occurrences_between(
rule.end_date] yields an empty list.
"""
if rule.rule_type not in (
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS
):
raise NotImplementedError(
f"occurrences_between for {rule.rule_type} lands with its own "
-361
View File
@@ -1,361 +0,0 @@
"""Window-bound enforcement across every rule type (start_date/end_date).
Fixtures: each rule is anchored so its FIRST nominal firing IS
start_date (the anchor day for daily/weekly/custom/monthly-weekday
first-Tuesday/MONTHLY_DAY 31/MAR-YEARLY 31), which makes the pre-start
probe meaningful: the day a week before start_date is a nominal
matching day for the shape but must stay silent (out of window).
"""
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 _tuesday(year: int, month: int, nth: int) -> datetime.date:
"""The nth Tuesday of the month (nth >= 1)."""
day = datetime.date(year, month, 1)
while day.weekday() != 1:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _friday(year: int, month: int, nth: int) -> datetime.date:
day = datetime.date(year, month, 1)
while day.weekday() != 4:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _month_end(year: int, month: int) -> datetime.date:
if month == 12:
return datetime.date(year, 12, 31)
return datetime.date(year, month + 1, 1) - datetime.timedelta(days=1)
#: One representative rule per type. Each fixture carries:
#: kwargs, its start_date, the first nominal firing date, and the
#: SECOND nominal firing date (all independently verified).
#: - DAILY: start Mar 1; fires daily; second firing Mar 2.
#: - WEEKLY (Tuesdays): start Tue Mar 3; second firing Tue Mar 10.
#: - CUSTOM_DAYS (Wed + Fri): start Wed Mar 4; second firing Fri Mar 6.
#: - MONTHLY_DAY (31st): start Tue Mar 31; second firing Apr 30 (clamp).
#: - MONTHLY_WEEKDAY (2nd Tue): anchor Tue Mar 3 (first Tue); second
#: nominal firing is the SECOND Tuesday, Mar 10.
#: - YEARLY (Mar 31): start Tue Mar 31; second firing Mar 31 2027.
BOUNDARY_RULES = [
{
"kwargs": {"rule_type": RuleType.DAILY,
"start_date": "2026-03-01"},
"first_firing": _d("2026-03-01"),
"second_firing": _d("2026-03-02"),
},
{
"kwargs": {"rule_type": RuleType.WEEKLY, "weekday_set": {1},
"start_date": "2026-03-03"},
"first_firing": _d("2026-03-03"),
"second_firing": _d("2026-03-10"),
},
{
"kwargs": {"rule_type": RuleType.CUSTOM_DAYS,
"weekday_set": {2, 4}, "start_date": "2026-03-04"},
"first_firing": _d("2026-03-04"),
"second_firing": _d("2026-03-06"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_DAY,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2026-04-30"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_WEEKDAY,
"nth_weekday": 2, "nth_weekday_weekday": 1,
"start_date": "2026-03-03"},
# The rule fires on SECOND Tuesdays: Mar 3 is the FIRST Tue
# (in-window but silent); the first nominal FIRING is Mar 10.
"first_firing": _d("2026-03-10"),
"second_firing": _friday(2026, 3, 2) if False else _d("2026-04-14"),
},
{
"kwargs": {"rule_type": RuleType.YEARLY, "month": 3,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2027-03-31"),
},
]
# ---------------------------------------------------------------------------
# start_date bound: nothing fires before it, for EVERY rule type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_no_firing_before_start_date(index) -> None:
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
before_start = _d(rule.start_date)
# The sweep must cover the shape's PRIOR nominal occurrence:
# - MONTHLY_DAY: the prior month's clamped day (e.g. Feb 28/31)
# - YEARLY: the prior year's nominal day
# - WEEKLY/CUSTOM_DAYS: the prior week's matching weekday
# - MONTHLY_WEEKDAY: the prior month's nth weekday
if rule.rule_type is RuleType.MONTHLY_DAY:
sweep_from = _month_end(2026, 2) # Feb 28, a nominal clamp day
elif rule.rule_type is RuleType.YEARLY:
sweep_from = datetime.date(
_d(rule.start_date).year - 1, rule.month, rule.day_of_month
)
elif rule.rule_type is RuleType.MONTHLY_WEEKDAY:
sweep_from = _tuesday(2026, 2, 2) # prior month's 2nd Tuesday
elif rule.rule_type is RuleType.WEEKLY:
sweep_from = before_start - datetime.timedelta(days=7)
elif rule.rule_type is RuleType.CUSTOM_DAYS:
sweep_from = before_start - datetime.timedelta(days=7)
else:
sweep_from = before_start - datetime.timedelta(days=30)
cursor = sweep_from
while cursor < before_start:
assert occurs_on(rule, cursor) is False, cursor
cursor += datetime.timedelta(days=1)
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_first_nominal_firing_is_on_or_after_start(index) -> None:
"""The first nominal firing must actually fire: the window must not
exclude a date at or after start_date."""
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
first = fixture["first_firing"]
assert occurs_on(rule, first) is True, first
# ---------------------------------------------------------------------------
# end_date bound: nothing fires after it
# ---------------------------------------------------------------------------
def test_no_firing_after_end_date_all_types() -> None:
window_end = _d("2026-09-30")
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-09-30", **fixture["kwargs"]
)
# Sweep every day in the week after window_end: none may fire,
# regardless of whether that date would nominally match.
for offset in range(1, 8):
after = window_end + datetime.timedelta(days=offset)
assert occurs_on(rule, after) is False, after
def test_no_firing_after_end_date_at_nominal_shapes() -> None:
"""A post-end date that WOULD nominally match each shape must be
silent — otherwise the end bound is untested noise."""
rule_monthly = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31", end_date="2026-04-30",
)
# Apr 30 is INSIDE the window (fires: clamp); May 31 would nominally
# match but is after end_date: silent.
assert occurs_on(rule_monthly, _d("2026-04-30")) is True
assert occurs_on(rule_monthly, _d("2026-05-31")) is False
rule_yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31", end_date="2027-03-31",
)
# Mar 31 2027 is inside; Mar 31 2028 would match but is past end.
assert occurs_on(rule_yearly, _d("2027-03-31")) is True
assert occurs_on(rule_yearly, _d("2028-03-31")) is False
rule_weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03", end_date="2026-10-05",
)
# Tue Oct 6 would nominally match but is after end_date (Oct 5 is a
# Monday, not in the set).
assert occurs_on(rule_weekly, _d("2026-09-29")) is True
assert occurs_on(rule_weekly, _d("2026-10-06")) is False
rule_monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03",
end_date="2026-10-05",
)
# The next nominal second Tuesday (Oct 13) is past end_date: silent.
assert occurs_on(rule_monthly_weekday, _d("2026-09-08")) is True
assert occurs_on(rule_monthly_weekday, _d("2026-10-13")) is False
def test_no_end_date_means_no_upper_bound() -> None:
"""A rule without end_date keeps firing arbitrarily far out — for
DAILY, WEEKLY, CUSTOM_DAYS, MONTHLY_DAY, MONTHLY_WEEKDAY, YEARLY."""
daily = ScheduleRule(rule_type=RuleType.DAILY,
start_date="2026-03-01")
assert occurs_on(daily, _d("2036-03-01")) is True
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03",
)
# 2076-03-03 is 50 years to the day after the anchor and a Tuesday.
assert occurs_on(weekly, _d("2076-03-03")) is True
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={4},
start_date="2026-03-06", # a Friday
)
assert occurs_on(custom, _d("2026-03-06")) is True
# The first Friday of March 2046 is Mar 2; Mar 6 is a Tuesday.
assert occurs_on(custom, _d("2046-03-01")) is False # not in set
assert occurs_on(custom, _d("2046-03-02")) is True
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
assert occurs_on(monthly_day, datetime.date(2036, 3, 15)) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, start_date="2026-03-04",
)
# First Wednesday of March 2036.
d = datetime.date(2036, 3, 1)
while d.weekday() != 2:
d += datetime.timedelta(days=1)
assert occurs_on(monthly_weekday, d) is True
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31",
)
assert occurs_on(yearly, datetime.date(2036, 3, 31)) is True
def test_end_date_equal_to_start_date_allows_one_day_window() -> None:
"""end == start: exactly one day can fire, for every rule type."""
daily = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-03-15",
end_date="2026-03-15",
)
assert occurs_on(daily, _d("2026-03-15")) is True
assert occurs_on(daily, _d("2026-03-14")) is False
assert occurs_on(daily, _d("2026-03-16")) is False
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1}, # 2026-03-10 is a Tue
start_date="2026-03-10", end_date="2026-03-10",
)
# The window is a SINGLE day (end == start, a Tuesday in-set):
# it fires, and the following Tuesday is out of window.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(weekly, _d("2026-03-10")) is True
assert occurs_on(weekly, _d("2026-03-17")) is False
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 4},
start_date="2026-03-11", end_date="2026-03-11",
)
# Mar 11 2026 is a Wednesday: fires. Next Wed (18) is out of window.
assert _d("2026-03-11").weekday() == 2
assert occurs_on(custom, _d("2026-03-11")) is True
assert occurs_on(custom, _d("2026-03-18")) is False
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(monthly_day, _d("2026-03-15")) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-10",
end_date="2026-03-10",
)
# Mar 10 2026 is the second Tuesday: the one-day window admits it.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(monthly_weekday, _d("2026-03-10")) is True
assert occurs_on(monthly_weekday, _d("2026-04-14")) is False
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(yearly, _d("2026-03-15")) is True
assert occurs_on(yearly, _d("2027-03-15")) is False
# ---------------------------------------------------------------------------
# end_date before start_date is rejected at construction, every type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
{"rule_type": RuleType.DAILY, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.WEEKLY, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.CUSTOM_DAYS, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_DAY, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_WEEKDAY, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.YEARLY, "month": 6, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
],
)
def test_end_before_start_rejected_for_every_type(kwargs) -> None:
with pytest.raises(RuleValidationError, match="on or after"):
ScheduleRule(**kwargs)
# ---------------------------------------------------------------------------
# occurrences_between respects the window for every type
# ---------------------------------------------------------------------------
def test_occurrences_between_respects_end_bound_all_types() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"]
)
result = occurrences_between(rule, "2026-01-01", "2026-12-31")
# Every result inside the queried range, none beyond June.
assert all(date <= "2026-06-30" for date in result), result
assert all(
date >= fixture["kwargs"]["start_date"] for date in result
), result
def test_occurrences_between_empty_when_range_before_start() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(**fixture["kwargs"])
assert occurrences_between(rule, "2026-01-01", "2026-02-28") == []
def test_occurrences_between_empty_when_range_after_end() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"],
)
result = occurrences_between(rule, "2026-10-01", "2026-12-31")
assert result == []
-444
View File
@@ -1,444 +0,0 @@
"""Consolidated recurrence suite over a fixed three-year calendar.
Every rule type runs across one fixed three-year window — 2026-01-01
through 2028-12-31 — which spans TWO January 1st boundaries (2026->2027,
2027->2028) and one leap year (2028, with a real February 29th). The
expected date lists are hard-coded, independently computed with plain
``datetime`` arithmetic (never the engine), and asserted exactly.
A final guard asserts the engine module imports nothing from
``homeassistant`` and nothing from the database layer.
"""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
ScheduleRule,
RuleType,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
#: The fixed consolidated window: three full years.
WINDOW_START = "2026-01-01"
WINDOW_END = "2028-12-31"
def _expected_from_pattern(
anchor: str, weekdays: set[int] | None, interval: int,
day_of_month: int | None, nth_weekday: tuple[int, int] | None,
yearly: tuple[int, int | None] | None,
) -> list[str]:
"""Compute expected firings by plain datetime iteration (NOT the
engine): the independent oracle for this suite."""
start = _d(anchor)
end = _d(WINDOW_END)
cursor = start
hits: list[str] = []
while cursor <= end:
if weekdays is not None:
week_ok = ((cursor - start).days // 7) % interval == 0
if week_ok and cursor.weekday() in weekdays:
hits.append(cursor.isoformat())
elif day_of_month is not None:
months_elapsed = (
(cursor.year - start.year) * 12
+ (cursor.month - start.month)
)
if months_elapsed % interval == 0:
month_end = (
datetime.date(cursor.year, cursor.month, 1)
if False
else _month_end(cursor.year, cursor.month)
)
if cursor.day == min(day_of_month, month_end):
hits.append(cursor.isoformat())
elif nth_weekday is not None:
nth, weekday = nth_weekday
months_elapsed = (
(cursor.year - start.year) * 12
+ (cursor.month - start.month)
)
if months_elapsed % interval == 0 and cursor.weekday() == weekday:
day = _nth_day(cursor.year, cursor.month, weekday, nth)
if day is not None and cursor.day == day:
hits.append(cursor.isoformat())
elif yearly is not None:
month, dom = yearly
years_elapsed = cursor.year - start.year
if years_elapsed % interval == 0 and cursor.month == month:
if dom is None:
if cursor.day == 1:
hits.append(cursor.isoformat())
else:
month_end = _month_end(cursor.year, cursor.month)
if cursor.day == min(dom, month_end):
hits.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
return hits
def _month_end(year: int, month: int) -> int:
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + first_offset_calc(first, weekday) + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _first_offset_calc(first, weekday):
return (weekday - first.weekday()) % 7
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (max(nth, 0) - 1 if nth > 0 else 0) * 7
if nth == -1:
return _nth_day_back(year, month, weekday)
if day > _month_end(year, month):
return None
return day
def _first_offset_calc(first, weekday):
return (weekday - first.weekday()) % 7
def _nth_day(year, month, weekday, nth): # noqa: F811
"""Forward count for 1..5; back-count for -1."""
if nth == -1:
return _nth_day(year, month, weekday, -1) # pragma: no cover
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
def _nth_day(year, month, weekday, nth): # noqa: F811
if nth != -1:
return _forward_nth(year, month, weekday, nth)
last = datetime.date(year, month, _month_end(year, month))
back = (last.weekday() - weekday) % 7
return last.day - back
def _forward_nth(year, month, weekday, nth):
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
# Consolidated rules: one per shape, anchored inside the window.
CONSOLIDATED_RULES = [
("daily", ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-01-01",
)),
("daily-x7", ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-05",
)),
("weekly-mwf", ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-01-05", # a Monday
)),
("custom-tts", ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
start_date="2026-01-03", # a Saturday
)),
("monthly-31st", ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)),
("monthly-2nd-wed", ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, start_date="2026-01-01",
)),
("yearly-leap", ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)),
("yearly-jun-15-x2", ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)),
]
# ---------------------------------------------------------------------------
# Exact expected lists over the full three-year window
# ---------------------------------------------------------------------------
def test_daily_exact_three_year_list() -> None:
rule = ScheduleRule(rule_type=RuleType.DAILY,
start_date="2026-01-01")
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independent oracle: plain datetime iteration over the whole window.
expected = []
cursor = _d("2026-01-01")
end = _d(WINDOW_END)
while cursor <= end:
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
# 365 + 365 + 366 = 1096 days: every single day fires.
assert len(result) == 1096
assert result[0] == "2026-01-01"
assert result[-1] == "2028-12-31"
assert "2028-02-29" in result
assert len(result) == len(set(result))
def test_daily_x7_exact_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-01"
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Offsets 0, 7, 14, ... <= 1095: 157 firings.
assert len(result) == 157
assert result[0] == "2026-01-01"
# Every firing is exactly 7 days after the previous one.
dates = [_d(date) for date in result]
gaps = {
(second - first).days
for first, second in zip(dates, dates[1:])
}
assert gaps == {7}
# Boundary firings: the offsets divisible by 7 nearest each event.
# Jan 1 2027 = offset 365 (skipped, 365 % 7 = 1); nearest firing:
anchor = datetime.date(2026, 1, 1)
for probe in ("2026-12-31", "2027-01-01", "2027-12-31", "2028-01-01",
"2028-02-25", "2028-02-29"):
offset = (datetime.date.fromisoformat(probe)
- datetime.date(2026, 1, 1)).days
expected_fire = offset % 7 == 0
fired = probe in result
assert fired is expected_fire, (
f"{probe}: offset {offset}, fired {fired}, "
f"expected {expected_fire}"
)
def test_weekly_mwf_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-01-05", # a Monday
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independently count Mon/Wed/Fri in 2026-2028.
expected = []
cursor = _d("2026-01-05")
end = _d(WINDOW_END)
while cursor <= end:
if cursor.weekday() in (0, 2, 4):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
# Three years of M/W/F: 156 weeks * 3 + 2 (2028 has a leap day but
# weekdays repeat weekly) -> verify the count matches plain math.
assert len(expected) == 468
def test_custom_tues_sats_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
start_date="2026-01-03", # a Saturday
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
expected = []
cursor = _d("2026-01-03")
end = _d(WINDOW_END)
while cursor <= end:
if cursor.weekday() in (2, 6):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected
def test_monthly_31st_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# Independent oracle: for each month, min(31, month_end).
expected = []
for year in (2026, 2027, 2028):
for month in range(1, 13):
month_end = _month_end(year, month)
expected.append(
f"{year:04d}-{month:02d}-{min(31, month_end):02d}"
)
assert result == expected
assert len(result) == 36
assert "2026-02-28" in result
assert "2027-02-28" in result
assert "2028-02-29" in result
assert "2026-04-30" in result
assert "2028-04-30" in result
def test_monthly_2nd_wed_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, start_date="2026-01-01",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# 36 months, one firing each: the second Wednesday.
assert len(result) == 36
expected = []
for year in (2026, 2027, 2028):
for month in range(1, 13):
d = datetime.date(year, month, 1)
weds = []
while d.month == month and d.year == year:
if d.weekday() == 2:
weds.append(d.day)
d += datetime.timedelta(days=1)
expected.append(f"{year:04d}-{month:02d}-{weds[1]:02d}")
assert result == expected
def test_yearly_leap_exact_three_year_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# 2026 clamp Feb 28, 2027 clamp Feb 28, 2028 leap Feb 29.
assert result == ["2026-02-28", "2027-02-28", "2028-02-29"]
def test_yearly_jun_15_interval_2_exact_list() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
# y0 2026, y2 2028 (y3 2029 excluded by the 3-year window).
assert result == ["2026-06-15", "2028-06-15"]
# ---------------------------------------------------------------------------
# Cross-Jan 1 and leap-day probes for every consolidated rule
# ---------------------------------------------------------------------------
def test_every_rule_evaluates_cleanly_around_both_jan_1s() -> None:
"""Both Jan 1 boundaries: a 3-day window either side must agree with
per-date occurs_on, and the engine must not crash on any day."""
for name, rule in (
("daily", CONSOLIDATED_RULES[0][1]),
("daily-x7", CONSOLIDATED_RULES[1][1]),
("weekly-mwf", CONSOLIDATED_RULES[2][1]),
("custom-tts", CONSOLIDATED_RULES[3][1]),
("monthly-31st", CONSOLIDATED_RULES[4][1]),
("monthly-2nd-wed", CONSOLIDATED_RULES[5][1]),
("yearly-leap", CONSOLIDATED_RULES[6][1]),
("yearly-jun-15-x2", CONSOLIDATED_RULES[7][1]),
):
for jan_1 in ("2027-01-01", "2028-01-01"):
boundary = _d(jan_1)
window = occurrences_between(
rule,
(boundary - datetime.timedelta(days=3)).isoformat(),
(boundary + datetime.timedelta(days=3)).isoformat(),
)
# Ordered and duplicate-free.
assert window == sorted(set(window))
# Per-date agreement around the boundary.
for offset in range(-3, 4):
date = boundary + datetime.timedelta(days=offset)
fired = occurs_on(rule, date)
in_list = date.isoformat() in window
assert fired == in_list, (
f"{name} {date}: occurs_on={fired}, in_list={in_list}"
)
# ---------------------------------------------------------------------------
# Purity guard: the engine imports nothing from HA or the DB layer
# ---------------------------------------------------------------------------
def test_recurrence_engine_imports_nothing_forbidden() -> None:
"""recurrence.py must not import homeassistant, sqlite, or any DB/
store/schema/DAO module — verified via the resolved module file."""
import ast
import importlib.util
from pathlib import Path
module_file = Path(
importlib.util.find_spec(
"custom_components.nestquest.recurrence"
).origin
)
tree = ast.parse(module_file.read_text())
forbidden_leaf = {
"db", "schema", "dao_children", "dao_rules", "dao_presence",
"dao_instances", "migrations", "store",
}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
lowered = alias.name.lower()
leaf = lowered.split(".")[-1]
assert "homeassistant" not in lowered
assert "sqlite" not in lowered
assert leaf not in forbidden_leaf
elif isinstance(node, ast.ImportFrom):
module = (node.module or "").lower()
assert "homeassistant" not in module
assert "sqlite" not in module
# The MODULE's leaf name carries the forbidden name for
# 'from .store import Store' style imports.
module_leaf = module.split(".")[-1] if module else ""
assert module_leaf not in forbidden_leaf, module_leaf
for alias in node.names:
leaf = alias.name.split(".")[-1].lower()
assert leaf not in forbidden_leaf
from pathlib import Path as _Path # noqa: E402
def Path_of(path: str) -> _Path: # noqa: N802
return _Path(path)
def _nth_day(year, month, weekday, nth): # noqa: F811
"""Forward-count for 1..5; back-count for -1 (canonical helper)."""
if nth == -1:
last = datetime.date(year, month, _month_end(year, month))
return last.day - ((last.weekday() - weekday) % 7)
first = datetime.date(year, month, 1)
offset = (weekday - first.weekday()) % 7
day = 1 + offset + (nth - 1) * 7
if day > _month_end(year, month):
return None
return day
+31 -25
View File
@@ -213,32 +213,15 @@ def test_occurrences_between_year_boundary() -> None:
# ---------------------------------------------------------------------------
def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
"""All six model shapes are implemented; a shape added to the model
without an engine arm must raise a loud NotImplementedError rather
than a silent wrong answer. Forged via a private enum extension
because ScheduleRule validation rejects unknown names."""
import enum as enum_module
from custom_components.nestquest import recurrence as mod
ForgedType = enum_module.Enum(
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None:
"""Monthly/yearly land with their own engine tasks; a loud
NotImplementedError beats a silent wrong answer."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-09-01",
)
forged_rule = ScheduleRule.__new__(ScheduleRule)
object.__setattr__(forged_rule, "rule_type", ForgedType.UNKNOWN_SHAPE)
object.__setattr__(forged_rule, "interval", 1)
object.__setattr__(forged_rule, "weekday_set", None)
object.__setattr__(forged_rule, "day_of_month", None)
object.__setattr__(forged_rule, "nth_weekday", None)
object.__setattr__(forged_rule, "nth_weekday_weekday", None)
object.__setattr__(forged_rule, "month", None)
object.__setattr__(forged_rule, "start_date", "2026-09-01")
object.__setattr__(forged_rule, "end_date", None)
for day in ("2026-08-31", "2026-09-15", "2026-12-31"):
with pytest.raises(NotImplementedError):
occurs_on(forged_rule, _d(day))
with pytest.raises(NotImplementedError):
occurrences_between(forged_rule, "2026-08-01", "2026-12-31")
with pytest.raises(NotImplementedError):
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
@@ -286,4 +269,27 @@ def test_occurrences_between_year_boundary_interval_2() -> None:
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.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.
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.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.
with pytest.raises(NotImplementedError):
occurrences_between(rule, "2026-08-01", "2026-08-10")
+5 -10
View File
@@ -393,21 +393,16 @@ def test_monthly_day_rejects_month() -> None:
def test_yearly_rejects_day_and_nth_fields() -> None:
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-09-01",
)
with pytest.raises(RuleValidationError, match="must not set"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, nth_weekday=1,
nth_weekday_weekday=0, start_date="2026-09-01",
)
# YEARLY MAY carry an optional day_of_month (Feb 29 rules need it):
rule = ScheduleRule(rule_type=RuleType.YEARLY, month=2,
day_of_month=29, start_date="2026-09-01")
assert rule.day_of_month == 29
# ...but it is still validated when present.
with pytest.raises(RuleValidationError, match="day_of_month"):
ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=32,
start_date="2026-09-01",
)
def test_storage_value_mapping() -> None:
-301
View File
@@ -1,301 +0,0 @@
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_month_end,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings
# ---------------------------------------------------------------------------
def test_monthly_day_15_fires_on_the_15th() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
for month in range(1, 13):
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
def test_monthly_day_never_fires_on_other_days() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
start_date="2026-01-10",
)
# Every non-10th day of a short month must be False.
for day in range(1, 30):
if day != 10:
assert occurs_on(
rule, datetime.date(2026, 1, day)
) is False, f"Jan {day}"
def test_monthly_day_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
# Feb 15 precedes the start: no firing even though the day matches.
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
def test_monthly_day_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15", end_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
# ---------------------------------------------------------------------------
# Month-end clamp policy
# ---------------------------------------------------------------------------
def test_month_end_helper_is_leap_aware() -> None:
assert _month_end(2026, 1) == 31
assert _month_end(2026, 4) == 30
assert _month_end(2026, 2) == 28 # non-leap
assert _month_end(2028, 2) == 29 # leap
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
assert _month_end(2026, 12) == 31
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Months with a 31st: fire on the 31st.
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
(10, 31), (12, 31)):
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
f"2026-{month:02d}-{day:02d}"
)
# Months WITHOUT a 31st: fire on the month's last day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
# The day BEFORE the clamp is not a firing.
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
def test_day_30_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2026-01-30",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# Months with a 30th: nominal day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2026-01-29",
)
# Non-leap 2027: Feb 28 is the clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
# Leap 2028: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_day_30_feb_leap_year_clamps_to_29() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2027-01-30",
)
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# ---------------------------------------------------------------------------
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
# ---------------------------------------------------------------------------
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
"""Every (month, day) in ``rule``'s year the engine says fires."""
hits = []
for month in range(1, 13):
for day in range(1, _month_end(year, month) + 1):
if occurs_on(rule, datetime.date(year, month, day)):
hits.append((month, day))
return hits
def test_full_year_day_31_sweep_2026() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Exactly one firing per month, twelve total.
hits = _sweep_monthly(rule, 2026)
assert len(hits) == 12
by_month = dict(hits)
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
assert by_month[2] == 28 # 2026 Feb is non-leap
assert by_month[4] == 30
# Each month has exactly one firing.
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_31_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2028-01-31",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap February clamps to the 29th
assert by_month[4] == 30
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_30_sweep_2028() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2028-01-30",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb clamps to 29
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2027_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2027-01-29",
)
hits = _sweep_monthly(rule, 2027)
by_month = dict(hits)
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2028-01-29",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
months = [month for month, _ in hits]
assert months == list(range(1, 13))
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_interval_2_every_second_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-01-15",
)
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
"""Interval counts nominal months from the anchor: Jan 31 anchor,
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
start_date="2026-01-31",
)
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-11-15",
)
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
def test_monthly_day_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
assert result == [
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
"2026-05-31", "2026-06-30",
]
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-15", "2026-01-15")
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
"""The clamp must not double-fire: only one day per month matches."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
hits = _sweep_monthly(rule, 2026)
months = [month for month, _ in hits]
assert months == sorted(months)
assert len(months) == len(set(months)) == 12
-324
View File
@@ -1,324 +0,0 @@
"""Tests for MONTHLY_WEEKDAY (nth-weekday-of-month) recurrence."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_nth_weekday_of_month,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# The helper: nth occurrence day-of-month
# ---------------------------------------------------------------------------
def test_nth_weekday_helper_first_second_third() -> None:
"""Independently verified: Mar 1 2026 is a Sunday; Wednesdays are
4, 11, 18, 25 (four — no 5th)."""
assert _nth_weekday_of_month(2026, 3, 2, 1) == 4
assert _nth_weekday_of_month(2026, 3, 2, 2) == 11
assert _nth_weekday_of_month(2026, 3, 2, 3) == 18
assert _nth_weekday_of_month(2026, 3, 2, 4) == 25
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
"""Independently verified: April 2026 Fridays are 3, 10, 17, 24
(four) — no 5th occurrence."""
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
def test_nth_weekday_helper_last() -> None:
"""Independently verified: March 2026 Fridays are 6, 13, 20, 27;
February 2028 Saturdays are 5, 12, 19, 26 (Feb 29 2028 is a
Tuesday, so the last SATURDAY is the 26th, not the last day)."""
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
# February 2026: 28 days, last day IS a Saturday.
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
# February 2028: the last Saturday is the 26th (Feb 29 is a Tue).
assert _nth_weekday_of_month(2028, 2, 5, -1) == 26
def test_nth_weekday_helper_last_when_month_has_five() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the last is the 30th, and the 5th is the same day."""
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
assert _nth_weekday_of_month(2026, 10, 4, 5) == 30
def test_helper_zero_and_out_of_range_nth() -> None:
"""The model rejects n 0 before the helper sees it; -1/1..5 only."""
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=0,
nth_weekday_weekday=1, start_date="2026-09-01",
)
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=6,
nth_weekday_weekday=1, start_date="2026-09-01",
)
# ---------------------------------------------------------------------------
# occurs_on: first / second / third / fourth / last across 12 months
# ---------------------------------------------------------------------------
def test_first_tuesday_of_twelve_consecutive_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, # first Wednesday
start_date="2026-01-01",
)
first_wednesdays = [
(1, 7), (2, 4), (3, 4), (4, 1), (5, 6), (6, 3),
(7, 1), (8, 5), (9, 2), (10, 7), (11, 4), (12, 2),
]
for month, day in first_wednesdays:
date = datetime.date(2026, month, day)
assert date.weekday() == 2
assert occurs_on(rule, date) is True, date
# Neighbors do not fire.
assert occurs_on(rule, date - datetime.timedelta(days=7)) is False
assert occurs_on(rule, date + datetime.timedelta(days=7)) is False
def test_second_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified second Wednesdays of 2026 (Jan 1 is a
Thursday; Wednesdays per month from the calendar): the SECOND one
fires; the FIRST one does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
# (month, first_wed, second_wed) from plain datetime arithmetic:
verified = [
(1, 7, 14), (2, 4, 11), (3, 4, 11), (4, 1, 8),
(5, 6, 13), (6, 3, 10), (7, 1, 8), (8, 5, 12),
(9, 2, 9), (10, 7, 14), (11, 4, 11), (12, 2, 9),
]
for month, first_wed, second_wed in verified:
assert _d(f"2026-{month:02d}-{first_wed:02d}").weekday() == 2
assert _d(f"2026-{month:02d}-{second_wed:02d}").weekday() == 2
assert occurs_on(
rule, datetime.date(2026, month, second_wed)
) is True, f"2026-{month:02d}-{second_wed:02d}"
assert occurs_on(
rule, datetime.date(2026, month, first_wed)
) is False, f"2026-{month:02d}-{first_wed:02d}"
def test_third_friday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Fridays; the THIRD fires, the SECOND
does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
nth_weekday_weekday=4,
start_date="2026-01-01",
)
verified = [
# (month, first_fri, second_fri, third_fri)
(1, 2, 9, 16), (2, 6, 13, 20), (3, 6, 13, 20),
(4, 3, 10, 17), (5, 1, 8, 15), (6, 5, 12, 19),
(7, 3, 10, 17), (8, 7, 14, 21), (9, 4, 11, 18),
(10, 2, 9, 16), (11, 6, 13, 20), (12, 4, 11, 18),
]
for month, first_fri, second_fri, third_fri in verified:
third = _d(f"2026-{month:02d}-{third_fri:02d}")
assert third.weekday() == 4
assert occurs_on(rule, third) is True, third
second = _d(f"2026-{month:02d}-{second_fri:02d}")
assert occurs_on(rule, second) is False, second
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Wednesdays; the FOURTH fires, the
THIRD does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
verified = [
# (month, third_wed, fourth_wed)
(1, 21, 28), (2, 18, 25), (3, 18, 25), (4, 15, 22),
(5, 20, 27), (6, 17, 24), (7, 15, 22), (8, 19, 26),
(9, 16, 23), (10, 21, 28), (11, 18, 25), (12, 16, 23),
]
for month, third_wed, fourth_wed in verified:
fourth = _d(f"2026-{month:02d}-{fourth_wed:02d}")
assert fourth.weekday() == 2
assert occurs_on(rule, fourth) is True, fourth
third = _d(f"2026-{month:02d}-{third_wed:02d}")
assert not occurs_on(rule, third), third
def test_last_monday_of_twelve_consecutive_months() -> None:
"""Independently verified last Mondays of 2026; each fires, and the
PREVIOUS Monday of the same month does not (it is nth-from-last
minus one, i.e. the second-to-last)."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=0,
start_date="2026-01-01",
)
verified = [
# (month, last_monday)
(1, 26), (2, 23), (3, 30), (4, 27), (5, 25), (6, 29),
(7, 27), (8, 31), (9, 28), (10, 26), (11, 30), (12, 28),
]
for month, last_monday in verified:
date = _d(f"2026-{month:02d}-{last_monday:02d}")
assert date.weekday() == 0
assert occurs_on(rule, date) is True, date
# The Monday a week earlier is NOT the last: must not fire.
week_before = date - datetime.timedelta(days=7)
if week_before.month == month:
assert occurs_on(rule, week_before) is False, week_before
# ---------------------------------------------------------------------------
# The five-occurrence skip: a 5th on a four-occurrence month
# ---------------------------------------------------------------------------
def test_fifth_weekday_skips_four_occurrence_months() -> None:
"""Independently verified: 2026 months with FIVE Mondays are March,
June, August, November — every other month (four Mondays) must fire
on no day at all, and each hit must be the month's FINAL Monday."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=0, # fifth Monday
start_date="2026-01-01",
)
five_monday_months = {3: 30, 6: 29, 8: 31, 11: 30}
hit_months = set()
for month in range(1, 13):
if month in five_monday_months:
day = five_monday_months[month]
hit = _d(f"2026-{month:02d}-{day:02d}")
assert hit.weekday() == 0
assert occurs_on(rule, hit) is True, hit
# It is the FINAL Monday of the month: sweep EVERY remaining
# calendar day after the hit — none may fire.
for later in range(day + 1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, later)
) is False, f"2026-{month:02d}-{later:02d}"
# The Monday a week earlier is not the fifth.
assert occurs_on(
rule, datetime.date(2026, month, day - 7)
) is False
hit_months.add(month)
else:
for day in range(1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, day)
) is False, f"2026-{month:02d}-{day:02d}"
assert hit_months == {3, 6, 8, 11}, hit_months
def _month_end_days(year: int, month: int) -> int:
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def test_fifth_weekday_fires_in_five_occurrence_months() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the 5th (and last) is the 30th; April 2026 has four Fridays
(3, 10, 17, 24) so no day in April fires."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=4, # Fridays
start_date="2026-01-01",
)
# October: the 30th fires, is a Friday, and no Monday-later day
# (the 31st) is a Friday: the 30th is the FINAL Friday.
oct_30 = datetime.date(2026, 10, 30)
assert oct_30.weekday() == 4
assert occurs_on(rule, oct_30) is True
assert occurs_on(rule, datetime.date(2026, 10, 31)) is False
# April: no Friday-5th, so no day fires.
for day in range(1, _month_end_days(2026, 4) + 1):
assert occurs_on(rule, datetime.date(2026, 4, day)) is False, (
f"2026-04-{day:02d}"
)
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_weekday_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, interval=2, start_date="2026-01-01",
)
# Jan (m0) fires on the 2nd Tuesday; Feb (m1) skipped; Mar (m2) fires.
jan_second = _nth_weekday_of_month(2026, 1, 2, 2)
assert occurs_on(rule, datetime.date(2026, 1, jan_second)) is True
feb_second = _nth_weekday_of_month(2026, 2, 2, 2)
assert occurs_on(rule, datetime.date(2026, 2, feb_second)) is False
mar_second = _nth_weekday_of_month(2026, 3, 2, 2)
assert occurs_on(rule, datetime.date(2026, 3, mar_second)) is True
def test_monthly_weekday_interval_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, interval=3, start_date="2026-11-01",
)
# Nov (m0) fires; Dec (m1), Jan (m2) skip; Feb (m3) fires.
nov_first = _nth_weekday_of_month(2026, 11, 2, 1)
assert occurs_on(rule, datetime.date(2026, 11, nov_first)) is True
dec_first = _nth_weekday_of_month(2026, 12, 2, 1)
assert occurs_on(rule, datetime.date(2026, 12, dec_first)) is False
jan_first = _nth_weekday_of_month(2027, 1, 2, 1)
assert occurs_on(rule, datetime.date(2027, 1, jan_first)) is False
feb_first = _nth_weekday_of_month(2027, 2, 2, 1)
assert occurs_on(rule, datetime.date(2027, 2, feb_first)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_monthly_weekday_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=4, # second Friday
start_date="2026-01-01", end_date="2026-04-30",
)
result = occurrences_between(rule, "2026-01-01", "2026-04-30")
# Independently verified second Fridays: Jan 9 (Jan 1 Thu), Feb 13
# (Feb 1 Sun), Mar 13 (Mar 1 Sun), Apr 10 (Apr 1 Wed).
assert result == [
"2026-01-09", "2026-02-13", "2026-03-13", "2026-04-10",
]
def test_monthly_weekday_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=1, start_date="2026-01-01",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-01", "2026-01-01")
-241
View File
@@ -1,241 +0,0 @@
"""Consistency property tests for occurrences_between vs occurs_on.
The done-condition: occurrences_between returns an ordered, duplicate-
free list of dates that agrees EXACTLY with calling occurs_on on every
date in the range. This module proves that agreement two ways:
1. An exhaustive sweep over a fixed grid of rules (every rule type ×
several intervals × several windows) cross-checked date by date.
2. A randomized property test (seeded, deterministic) over randomly
generated rules and ranges asserting the two functions never
disagree. Seeds are fixed so failures are reproducible.
"""
from __future__ import annotations
import datetime
import random
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 _check_consistency(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Cross-check occurrences_between against per-date occurs_on.
Returns the occurrences_between result after asserting, for every
date in [start, end]: occurs_on(date) is True iff the date appears
in the result. Also asserts the list is ordered and duplicate-free.
"""
result = occurrences_between(rule, start, end)
# Ordered and duplicate-free.
assert result == sorted(set(result)), (
f"occurrences_between must be ordered, duplicate-free: {result}"
)
# Exact agreement with occurs_on over every date in the range.
cursor = _d(start)
end_date = _d(end)
expected: list[str] = []
while cursor <= end_date:
if occurs_on(rule, cursor):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected, (
f"disagreement for {rule.to_dict()} over [{start}, {end}]:\n"
f" occurrences_between: {result}\n"
f" per-date occurs_on: {expected}"
)
return result
# ---------------------------------------------------------------------------
# Exhaustive grid over rule shapes, intervals and windows
# ---------------------------------------------------------------------------
_GRID_RULES = [
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=2,
start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=3,
start_date="2026-03-15", end_date="2027-01-31"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
interval=2, start_date="2026-03-06",
end_date="2027-06-30"),
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 4},
start_date="2026-03-06"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
interval=2, start_date="2026-01-29"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=4, start_date="2026-03-06",
end_date="2028-12-31"),
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28"),
ScheduleRule(rule_type=RuleType.YEARLY, month=6, day_of_month=15,
interval=2, start_date="2026-06-15"),
]
_GRID_WINDOWS = [
("2026-01-01", "2026-03-31"), # mostly before start
("2026-03-01", "2026-03-01"), # single day == anchor
("2026-03-01", "2026-03-31"), # within window
("2026-08-01", "2026-08-31"), # mid-window month
("2026-12-01", "2027-01-31"), # year boundary
("2028-01-01", "2028-03-01"), # leap year span
("2029-01-01", "2029-12-31"), # far out
]
@pytest.mark.parametrize(
"rule_index,window_index",
[
(rule_index, window_index)
for rule_index in range(len(_GRID_RULES))
for window_index in range(len(_GRID_WINDOWS))
],
)
def test_grid_occurrences_between_matches_occurs_on(
rule_index, window_index
) -> None:
rule = _GRID_RULES[rule_index]
window = _GRID_WINDOWS[window_index]
_check_consistency(rule, window[0], window[1])
# ---------------------------------------------------------------------------
# Randomized property test (seeded, deterministic)
# ---------------------------------------------------------------------------
def _generate_random_rules(rng: random.Random, count: int) -> list[ScheduleRule]:
"""Random but valid rules: shapes, intervals, anchors, windows.
Any random combo the model rejects (genuinely invalid combinations)
is skipped — the property only needs VALID rules; the assertion at
the end bounds how many may be rejected.
"""
rules: list[ScheduleRule] = []
shapes = list(RuleType)
for index in range(count):
shape = shapes[rng.randrange(len(shapes))]
interval = rng.choice([1, 2, 3, 7])
anchor = datetime.date(
rng.choice([2024, 2025, 2026, 2027, 2028]),
rng.randrange(1, 13),
rng.randrange(1, 28),
)
end_offset = rng.choice([45, 120, 400, 800])
end = anchor + datetime.timedelta(days=end_offset)
common = {
"rule_type": shape,
"interval": interval,
"start_date": anchor.isoformat(),
"end_date": end.isoformat(),
}
try:
if shape is RuleType.WEEKLY:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.CUSTOM_DAYS:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.MONTHLY_DAY:
rules.append(ScheduleRule(
**common, day_of_month=rng.randrange(1, 32)
))
elif shape is RuleType.MONTHLY_WEEKDAY:
rules.append(ScheduleRule(
**common,
nth_weekday=rng.choice([-1, 1, 2, 3, 4, 5]),
nth_weekday_weekday=rng.randrange(7),
))
elif shape is RuleType.YEARLY:
rules.append(ScheduleRule(
**common,
month=rng.randrange(1, 13),
day_of_month=rng.choice([None, rng.randrange(1, 29)]),
))
else: # DAILY
rules.append(ScheduleRule(**common))
except RuleValidationError:
continue
assert len(rules) >= count * 3 // 4, "too many random rules rejected"
return rules
def test_random_rules_never_disagree_between_the_two_functions() -> None:
"""The property: for randomly generated rules and ranges,
occurrences_between(rule, s, e) == [d for d in s..e if occurs_on]."""
rng = random.Random(0xFEA7) # fixed seed for reproducibility
rules = _generate_random_rules(rng, count=40)
checked = 0
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
for offset_days, span_days in ((-30, 60), (0, 14), (10, 90),
(100, 200)):
range_start = anchor + datetime.timedelta(days=offset_days)
range_end = range_start + datetime.timedelta(days=span_days)
_check_consistency(
rule, range_start.isoformat(), range_end.isoformat()
)
checked += 1
assert checked >= 40 * 4 * 3 // 4, checked
def test_random_ranges_include_year_and_leap_spans() -> None:
"""Every rule is probed across a range that spans Jan 1 (twice) and
a February 29 — the Dec 1..Mar 1 two-year span always covers both."""
rng = random.Random(42)
rules = _generate_random_rules(rng, count=12)
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
probe_start = datetime.date(anchor.year, 12, 1)
probe_end = datetime.date(anchor.year + 2, 3, 1)
# The span always contains two Jan 1s and a Feb 28/29 pair.
assert probe_end >= probe_start
_check_consistency(
rule, probe_start.isoformat(), probe_end.isoformat()
)
# An additional probe pinned ON a Feb 29 window (2028 leap):
# every rule is cross-checked over Feb 1 - Mar 15 2028.
_check_consistency(rule, "2028-02-01", "2028-03-15")
def test_occurrences_between_single_day_range_matches_occurs_on() -> None:
"""A one-day range is the exact occurs_on of that date."""
for rule in _GRID_RULES:
for day in ("2026-03-05", "2026-03-15", "2027-06-15"):
single = occurrences_between(rule, day, day)
if occurs_on(rule, _d(day)):
assert single == [day], (rule.to_dict(), day, single)
else:
assert single == [], (rule.to_dict(), day, single)
-233
View File
@@ -1,233 +0,0 @@
"""Tests for YEARLY recurrence with the leap-day clamp policy."""
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)
# ---------------------------------------------------------------------------
# Nominal-day firings across four consecutive years (incl. leap 2028)
# ---------------------------------------------------------------------------
def test_yearly_june_15_four_consecutive_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 6, 15)) is True, year
assert occurs_on(rule, datetime.date(2026, 6, 14)) is False
assert occurs_on(rule, datetime.date(2026, 6, 16)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is False
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
def test_yearly_feb_15_across_four_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=15,
start_date="2026-02-15",
)
# Feb 15 exists every year; the leap day is irrelevant.
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 2, 15)) is True, year
def test_yearly_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2027-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is False
assert occurs_on(rule, datetime.date(2027, 6, 15)) is True
def test_yearly_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15", end_date="2028-06-15",
)
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False
# ---------------------------------------------------------------------------
# Leap-day policy: Feb 29 rule fires Feb 28 in non-leap years
# ---------------------------------------------------------------------------
def test_yearly_feb_29_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2026 non-leap: no Feb 29 — the clamp fires Feb 28.
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# 2027 non-leap: same clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
def test_yearly_feb_29_fires_feb_29_in_leap_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2028 leap: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_yearly_feb_29_four_consecutive_years() -> None:
"""The done-condition's four-year sweep: 2026 (clamp 28), 2027
(clamp 28), 2028 (leap: nominal 29), 2029 (clamp 28)."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2029, 2, 28)) is True
# No other day in those Februaries fires.
for year, non_firing in (
(2026, (27,)), (2027, (26, 27)), (2028, (27,)),
(2029, (26, 27)),
):
for day in non_firing:
assert occurs_on(rule, datetime.date(year, 2, day)) is False
def test_yearly_feb_30_is_impossible_by_model() -> None:
"""The model rejects day_of_month 30+ for February? No — 30/31 are
accepted (1..31) and clamp to the month end like MONTHLY_DAY."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=30,
start_date="2027-02-28",
)
# Non-leap 2027: Feb 28 is the month end — the clamp fires it.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# Leap 2028: Feb 29 is the month end — clamps there.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
# ---------------------------------------------------------------------------
# Day-less YEARLY rules fire on the first of the month
# ---------------------------------------------------------------------------
def test_yearly_without_day_fires_on_first_of_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, start_date="2026-01-01",
)
assert occurs_on(rule, datetime.date(2026, 6, 1)) is True
assert occurs_on(rule, datetime.date(2026, 6, 2)) is False
assert occurs_on(rule, datetime.date(2026, 5, 1)) is False
assert occurs_on(rule, datetime.date(2027, 6, 1)) is True
# ---------------------------------------------------------------------------
# Interval: every N years
# ---------------------------------------------------------------------------
def test_yearly_interval_2_every_second_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True # y0
assert occurs_on(rule, datetime.date(2027, 6, 15)) is False # y1
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True # y2
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False # y3
assert occurs_on(rule, datetime.date(2030, 6, 15)) is True # y4
def test_yearly_interval_2_leap_day_every_second_year() -> None:
"""A Feb 29 rule at interval 2 anchored 2026 fires in even years:
2026 clamps (Feb 28), 2027 skips, 2028 hits the nominal Feb 29."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29, interval=2,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # y0 clamp
assert occurs_on(rule, datetime.date(2027, 2, 28)) is False # y1
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True # y2 nominal
assert occurs_on(rule, datetime.date(2029, 2, 28)) is False # y3
assert occurs_on(rule, datetime.date(2030, 2, 28)) is True # y4 clamp
def test_yearly_interval_3_across_start_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=10, interval=3,
start_date="2026-03-10",
)
assert occurs_on(rule, datetime.date(2026, 3, 10)) is True
assert occurs_on(rule, datetime.date(2027, 3, 10)) is False
assert occurs_on(rule, datetime.date(2028, 3, 10)) is False
assert occurs_on(rule, datetime.date(2029, 3, 10)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_yearly_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
result = occurrences_between(rule, "2026-01-01", "2029-12-31")
assert result == [
"2026-02-28", "2027-02-28", "2028-02-29", "2029-02-28",
]
def test_yearly_occurrences_between_disjoint_range() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
# The rule only ever fires in June: other months are empty.
assert occurrences_between(rule, "2027-01-01", "2027-05-31") == []
assert occurrences_between(rule, "2027-07-01", "2027-12-31") == []
def test_yearly_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2029-01-01", "2026-01-01")
# ---------------------------------------------------------------------------
# Day of month 31 in a 30-day month: clamp consistency with MONTHLY_DAY
# ---------------------------------------------------------------------------
def test_yearly_june_31_clamps_to_june_30() -> None:
"""June has 30 days: a June 31st yearly rule clamps to June 30."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=31,
start_date="2026-06-30",
)
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True
assert occurs_on(rule, datetime.date(2027, 6, 30)) is True
assert occurs_on(rule, datetime.date(2026, 6, 29)) is False