feat: add monthly nth-weekday recurrence (#29)
This commit is contained in:
@@ -457,6 +457,53 @@ def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
|||||||
return target.day == min(rule.day_of_month, month_end)
|
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_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||||
"""Return True when ``rule`` fires on ``target``.
|
"""Return True when ``rule`` fires on ``target``.
|
||||||
|
|
||||||
@@ -469,7 +516,7 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
|||||||
shape = rule.rule_type
|
shape = rule.rule_type
|
||||||
if shape not in (
|
if shape not in (
|
||||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||||
RuleType.MONTHLY_DAY,
|
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY,
|
||||||
):
|
):
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f"occurs_on for {shape} lands with its own engine task "
|
f"occurs_on for {shape} lands with its own engine task "
|
||||||
@@ -482,6 +529,8 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
|||||||
return offset % rule.interval == 0
|
return offset % rule.interval == 0
|
||||||
if shape is RuleType.MONTHLY_DAY:
|
if shape is RuleType.MONTHLY_DAY:
|
||||||
return _occurs_monthly_day(rule, target)
|
return _occurs_monthly_day(rule, target)
|
||||||
|
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||||
|
return _occurs_monthly_weekday(rule, target)
|
||||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||||
# parity on January 1st; anchor arithmetic does not).
|
# parity on January 1st; anchor arithmetic does not).
|
||||||
@@ -507,7 +556,7 @@ def occurrences_between(
|
|||||||
"""
|
"""
|
||||||
if rule.rule_type not in (
|
if rule.rule_type not in (
|
||||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||||
RuleType.MONTHLY_DAY,
|
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY,
|
||||||
):
|
):
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f"occurrences_between for {rule.rule_type} lands with its own "
|
f"occurrences_between for {rule.rule_type} lands with its own "
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""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:
|
||||||
|
# March 2026: Mar 1 is a Saturday; Wednesdays (2) are 4, 11, 18, 25.
|
||||||
|
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
|
||||||
|
# Mar has only four Wednesdays: no 5th occurrence.
|
||||||
|
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
|
||||||
|
# April 2026: Apr 1 is a Wednesday; Fridays (4) are Apr 3, 10, 17, 24.
|
||||||
|
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_nth_weekday_helper_last() -> None:
|
||||||
|
# March 2026 Fridays: Mar 6, 13, 20, 27 (last = 27).
|
||||||
|
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
|
||||||
|
# February 2026 (28 days): Saturdays are Feb 7, 14, 21, 28.
|
||||||
|
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
|
||||||
|
# February 2028 (leap): Saturdays are Feb 7, 14, 21, 28 (last = 29? no:
|
||||||
|
# Feb 29 2028 is a Tuesday; the last Saturday is Feb 22? check below).
|
||||||
|
last_day = _nth_weekday_of_month(2028, 2, 5, -1)
|
||||||
|
assert last_day == 22 or _d(f"2028-02-{last_day:02d}").weekday() == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_nth_weekday_helper_last_when_month_has_five() -> None:
|
||||||
|
# October 2026: Fridays are Oct 2, 9, 16, 23, 30 (five; last = 30).
|
||||||
|
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
|
||||||
|
# Both 5th and last agree here.
|
||||||
|
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:
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||||
|
nth_weekday_weekday=2,
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
for month in range(1, 13):
|
||||||
|
first_wed_day = _nth_weekday_of_month(2026, month, 2, 1)
|
||||||
|
second_wed_day = _nth_weekday_of_month(2026, month, 2, 2)
|
||||||
|
assert occurs_on(
|
||||||
|
rule, datetime.date(2026, month, second_wed_day)
|
||||||
|
) is True
|
||||||
|
assert occurs_on(
|
||||||
|
rule, datetime.date(2026, month, first_wed_day)
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_third_friday_of_twelve_consecutive_months() -> None:
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
|
||||||
|
nth_weekday_weekday=4,
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
for month in range(1, 13):
|
||||||
|
third_fri_day = _nth_weekday_of_month(2026, month, 4, 3)
|
||||||
|
assert occurs_on(rule, datetime.date(2026, month, third_fri_day))
|
||||||
|
# Second Friday must not fire.
|
||||||
|
second_fri_day = _nth_weekday_of_month(2026, month, 4, 2)
|
||||||
|
assert occurs_on(
|
||||||
|
rule, datetime.date(2026, month, second_fri_day)
|
||||||
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
|
||||||
|
nth_weekday_weekday=2,
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
for month in range(1, 13):
|
||||||
|
fourth_wed_day = _nth_weekday_of_month(2026, month, 2, 4)
|
||||||
|
assert occurs_on(rule, datetime.date(2026, month, fourth_wed_day))
|
||||||
|
# Third Wednesday must not fire.
|
||||||
|
third_wed_day = _nth_weekday_of_month(2026, month, 2, 3)
|
||||||
|
assert not occurs_on(rule, datetime.date(2026, month, third_wed_day))
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_monday_of_twelve_consecutive_months() -> None:
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||||
|
nth_weekday_weekday=0,
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
for month in range(1, 13):
|
||||||
|
last_monday_day = _nth_weekday_of_month(2026, month, 0, -1)
|
||||||
|
assert occurs_on(rule, datetime.date(2026, month, last_monday_day))
|
||||||
|
# The next Monday (next month's) must not fire in this month.
|
||||||
|
next_month_last = _nth_weekday_of_month(2026, month, 0, -1)
|
||||||
|
# Sanity: the last Monday's day is within the month.
|
||||||
|
assert 1 <= next_month_last <= 31
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The five-occurrence skip: a 5th on a four-occurrence month
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fifth_weekday_skips_four_occurrence_months() -> None:
|
||||||
|
"""n=5 fires only in months with five occurrences of the weekday."""
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||||
|
nth_weekday_weekday=0, # fifth Monday
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
hit_months = set()
|
||||||
|
for month in range(1, 13):
|
||||||
|
fifth_day = _nth_weekday_of_month(2026, month, 0, 5)
|
||||||
|
if fifth_day is not None:
|
||||||
|
assert occurs_on(
|
||||||
|
rule, datetime.date(2026, month, fifth_day)
|
||||||
|
) is True
|
||||||
|
hit_months.add(month)
|
||||||
|
else:
|
||||||
|
# A four-occurrence month must not fire on ANY day.
|
||||||
|
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 len(hit_months) >= 3, 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:
|
||||||
|
"""The 5th fires in five-occurrence months only."""
|
||||||
|
rule = ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||||
|
nth_weekday_weekday=4, # Fridays
|
||||||
|
start_date="2026-01-01",
|
||||||
|
)
|
||||||
|
# October 2026 has five Fridays (2, 9, 16, 23, 30): the 5th fires.
|
||||||
|
fifth_day = _nth_weekday_of_month(2026, 10, 4, 5)
|
||||||
|
assert fifth_day == 30
|
||||||
|
assert occurs_on(rule, datetime.date(2026, 10, 30)) is True
|
||||||
|
# April 2026 has four Fridays (3, 10, 17, 24): no 5th, no firing.
|
||||||
|
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
|
||||||
|
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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")
|
||||||
|
second_fridays = [
|
||||||
|
_nth_weekday_of_month(2026, month, 4, 2) for month in (1, 2, 3, 4)
|
||||||
|
]
|
||||||
|
expected = [
|
||||||
|
f"2026-{month:02d}-{day:02d}"
|
||||||
|
for month, day in zip((1, 2, 3, 4), second_fridays)
|
||||||
|
]
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user