From ceb32074e7df1465a730317256917af19dcafd02 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 10:54:45 -0400 Subject: [PATCH 01/10] Define the ScheduleRule model and serialization (#25) --- custom_components/nestquest/recurrence.py | 389 ++++++++++++++++++ tests/test_recurrence_model.py | 480 ++++++++++++++++++++++ 2 files changed, 869 insertions(+) create mode 100644 custom_components/nestquest/recurrence.py create mode 100644 tests/test_recurrence_model.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py new file mode 100644 index 0000000..ba7f605 --- /dev/null +++ b/custom_components/nestquest/recurrence.py @@ -0,0 +1,389 @@ +"""The ScheduleRule model: rule types, validation, and serialization. + +A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure +frozen dataclass with no Home Assistant and no database imports. The +evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02) +serializes its dict form into the ``schedule_rules`` table. + +Type model: one enum member per recurrence shape. ``MONTHLY_DAY`` +("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second +Tuesday of every month") are distinct types rather than one monthly +type with optional fields, so validation is total: a rule either +constructs, or raises :class:`RuleValidationError` naming the offending +field. Every type carries ONLY the fields its shape needs; carrying +any other shape's field is a construction error. + +Storage mapping (Feature 02, ``schedule_rules`` table): the enum's +``storage_value`` maps model types onto the schema's CHECK values — +MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are +disambiguated by which fields are populated; CUSTOM_DAYS stores as +``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the +table stores (one element for MONTHLY_WEEKDAY, which names the +position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field +carried through from_dict/to_dict for losslessness and folded into +``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the +model-side lossless pair; the DB mapping lives in the DAO layer and is +exercised there. + +Validation policy (fail fast at construction — the schema's CHECK +constraints are a second line of defense, but invalid rules must never +reach storage): + +- ``interval`` >= 1 for every rule. +- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden + elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans. +- ``day_of_month`` 1..31 is required for MONTHLY_DAY. +- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is + rejected) with a weekday 0..6 for MONTHLY_WEEKDAY. +- ``month`` 1..12 is required for YEARLY. +- ``start_date`` is a strict ISO date; ``end_date`` is optional and, + when present, on or after ``start_date``. + +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 + +import datetime +import enum +from dataclasses import dataclass, field + +#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers. +_DATE_FORMAT = "%Y-%m-%d" + +#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order). + + +class RuleType(enum.Enum): + """The six recurrence shapes the engine supports. + + ``storage_value`` is the string written to the ``schedule_rules`` + table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY + and MONTHLY_WEEKDAY are distinct shapes sharing one storage value, + disambiguated by which fields are populated. + """ + + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY_DAY = "monthly_day" + MONTHLY_WEEKDAY = "monthly_weekday" + YEARLY = "yearly" + CUSTOM_DAYS = "custom_days" + + @property + def storage_value(self) -> str: + """The schema-level rule_type string this model type maps to.""" + return { + RuleType.DAILY: "daily", + RuleType.WEEKLY: "weekly", + RuleType.MONTHLY_DAY: "monthly", + RuleType.MONTHLY_WEEKDAY: "monthly", + RuleType.YEARLY: "yearly", + RuleType.CUSTOM_DAYS: "custom", + }[self] + + +class RuleValidationError(ValueError): + """Raised when a ScheduleRule field combination is invalid.""" + + +def _validate_date(value: str, field_name: str) -> None: + """Raise unless ``value`` is a strict YYYY-MM-DD calendar date.""" + try: + parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date() + except (TypeError, ValueError): + raise RuleValidationError( + f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}" + ) from None + if parsed.isoformat() != value: + raise RuleValidationError( + f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}" + ) + + +def _validate_weekday_set(weekday_set) -> frozenset[int]: + """Return a normalized frozenset or raise with a clear message. + + Sequence entries are validated BEFORE set() coercion: a list like + [0, False] must not collapse the boolean into 0, and unhashable + entries must raise RuleValidationError, never a raw TypeError. + """ + if isinstance(weekday_set, (list, tuple)): + for entry in weekday_set: + # _is_plain_int: bool and int subclasses (potentially + # unhashable) are rejected before set() coercion. + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + weekday_set = set(weekday_set) + if not isinstance(weekday_set, (set, frozenset)): + raise RuleValidationError( + f"weekday_set must be a set of weekday integers, got " + f"{type(weekday_set).__name__}" + ) + if not weekday_set: + raise RuleValidationError( + "weekday_set must not be empty for weekly/custom rules" + ) + for entry in weekday_set: + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + if not 0 <= entry <= 6: + raise RuleValidationError( + f"weekday_set entries must be 0 (Monday)..6 (Sunday), " + f"got {entry!r}" + ) + return frozenset(weekday_set) + + +def _is_plain_int(value) -> bool: + """True only for real ints: bool excluded AND int subclasses too + (an unhashable int subclass would break the frozen-hashable model).""" + return type(value) is int + + +def _validate_monthly_weekday(nth_weekday, weekday) -> None: + """Validate the MONTHLY_WEEKDAY position fields.""" + if nth_weekday is None or not _is_plain_int(nth_weekday): + raise RuleValidationError( + "nth_weekday must be an integer for monthly_weekday rules, " + f"got {nth_weekday!r}" + ) + if not (-1 <= nth_weekday <= 5) or nth_weekday == 0: + raise RuleValidationError( + f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}" + ) + if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6: + raise RuleValidationError( + f"monthly_weekday rules require a weekday 0 (Monday)..6 " + f"(Sunday), got {weekday!r}" + ) + + + + + +@dataclass(frozen=True) +class ScheduleRule: + """An immutable, validated recurrence rule (see module docstring). + + Construct with keyword arguments; invalid combinations raise + :class:`RuleValidationError` at construction time. Fields map + one-to-one onto the ``schedule_rules`` table columns, with + ``nth_weekday_weekday`` as the model-only weekday companion to + ``nth_weekday`` (serialized inside ``weekday_set`` at the DAO + boundary). + """ + + rule_type: RuleType + interval: int = 1 + weekday_set: frozenset[int] | None = None + day_of_month: int | None = None + nth_weekday: int | None = None + nth_weekday_weekday: int | None = None + month: int | None = None + start_date: str = "1970-01-01" + end_date: str | None = None + + def __post_init__(self) -> None: + # Normalize a string rule_type into the enum; anything else + # that is not already a RuleType is rejected. + if isinstance(self.rule_type, str): + # EXACT model names only: no whitespace stripping, no storage + # aliases. The DAO maps storage strings to model fields when + # reading rows; the model boundary stays unambiguous. + candidates = {rt.name.lower(): rt for rt in RuleType} + if self.rule_type in candidates: + object.__setattr__( + self, "rule_type", candidates[self.rule_type] + ) + else: + raise RuleValidationError( + f"rule_type must be a RuleType or one of " + f"{sorted(candidates)}, got {self.rule_type!r}" + ) + if not isinstance(self.rule_type, RuleType): + raise RuleValidationError( + f"rule_type must be a RuleType or one of its string " + f"values, got {self.rule_type!r}" + ) + if not _is_plain_int(self.interval): + raise RuleValidationError( + f"interval must be an integer >= 1, got {self.interval!r}" + ) + if self.interval < 1: + raise RuleValidationError( + f"interval must be >= 1, got {self.interval!r}" + ) + + _validate_date(self.start_date, "start_date") + if self.end_date is not None: + _validate_date(self.end_date, "end_date") + if self.end_date < self.start_date: + raise RuleValidationError( + f"end_date {self.end_date!r} must be on or after " + f"start_date {self.start_date!r}" + ) + + # Type-exclusive field policy, enforced for every type: build + # the dict of present fields, then reject every field the shape + # must not carry (weekday_set present is detectable via None vs + # set even after normalization below). + shape = self.rule_type + if shape is RuleType.DAILY: + forbidden_fields = ( + "weekday_set", + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.WEEKLY: + forbidden_fields = ( + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.CUSTOM_DAYS: + forbidden_fields = ( + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + ) + elif shape is RuleType.MONTHLY_DAY: + forbidden_fields = ("weekday_set", "nth_weekday", + "nth_weekday_weekday", "month") + elif shape is RuleType.MONTHLY_WEEKDAY: + forbidden_fields = ("weekday_set", "day_of_month", "month") + else: # YEARLY + forbidden_fields = ( + "weekday_set", + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + ) + present_forbidden = [ + name + for name in forbidden_fields + if getattr(self, name) is not None + ] + if present_forbidden: + raise RuleValidationError( + f"{shape.value} rules must not set " + f"{', '.join(sorted(present_forbidden))}" + ) + + # Required fields per shape. + if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS: + normalized = _validate_weekday_set(self.weekday_set) + object.__setattr__(self, "weekday_set", normalized) + elif shape is RuleType.MONTHLY_DAY: + if self.day_of_month is None or not _is_plain_int( + self.day_of_month + ): + raise RuleValidationError( + "monthly_day rules require day_of_month (1..31)" + ) + if not 1 <= self.day_of_month <= 31: + raise RuleValidationError( + f"day_of_month must be 1..31, got {self.day_of_month!r}" + ) + elif shape is RuleType.MONTHLY_WEEKDAY: + _validate_monthly_weekday( + self.nth_weekday, self.nth_weekday_weekday + ) + elif shape is RuleType.YEARLY: + if self.month is None or not _is_plain_int(self.month): + raise RuleValidationError( + "yearly rules require month (1..12)" + ) + if not 1 <= self.month <= 12: + raise RuleValidationError( + f"month must be 1..12, got {self.month!r}" + ) + + def to_dict(self) -> dict: + """Return the lossless dict form (model-side storage shape). + + ``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's + weekday travels inside it (appended) plus + ``nth_weekday_weekday`` so the pair round-trips without + ambiguity at the model layer. + """ + return { + "rule_type": self.rule_type.name.lower(), # model name + "interval": self.interval, + "weekday_set": ( + sorted(self.weekday_set) if self.weekday_set is not None + else None + ), + "day_of_month": self.day_of_month, + "nth_weekday": self.nth_weekday, + "nth_weekday_weekday": self.nth_weekday_weekday, + "month": self.month, + "start_date": self.start_date, + "end_date": self.end_date, + } + + @classmethod + def from_dict(cls, data: dict) -> "ScheduleRule": + """Rebuild a rule from :meth:`to_dict` output. + + Raises :class:`RuleValidationError` on unknown keys, missing + keys, or invalid combinations — never a raw TypeError. + """ + if not isinstance(data, dict): + raise RuleValidationError( + f"rule data must be a dict, got {type(data).__name__}" + ) + allowed = { + "rule_type", + "interval", + "weekday_set", + "day_of_month", + "nth_weekday", + "nth_weekday_weekday", + "month", + "start_date", + "end_date", + } + unknown = [key for key in data if key not in allowed] + if unknown: + raise RuleValidationError( + f"unknown rule fields: {sorted(unknown, key=repr)}" + ) + if "rule_type" not in data: + raise RuleValidationError("missing required rule field: rule_type") + weekday_set = data.get("weekday_set") + if isinstance(weekday_set, (list, tuple)): + # Validate entries as plain ints BEFORE set() coercion, so a + # list like [0, False] cannot collapse False into 0 and an + # unhashable/int-subclass entry cannot leak a raw TypeError. + for entry in weekday_set: + if not _is_plain_int(entry): + raise RuleValidationError( + f"weekday_set entries must be plain integers, got " + f"{entry!r}" + ) + weekday_set = set(weekday_set) + return cls( + rule_type=data["rule_type"], + interval=data.get("interval", 1), + weekday_set=weekday_set, + day_of_month=data.get("day_of_month"), + nth_weekday=data.get("nth_weekday"), + nth_weekday_weekday=data.get("nth_weekday_weekday"), + month=data.get("month"), + start_date=data.get("start_date", "1970-01-01"), + end_date=data.get("end_date"), + ) diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py new file mode 100644 index 0000000..5d0def5 --- /dev/null +++ b/tests/test_recurrence_model.py @@ -0,0 +1,480 @@ +"""Tests for recurrence.py: the ScheduleRule model and serialization.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, +) + + +def _rule(**kwargs) -> ScheduleRule: + return ScheduleRule(**kwargs) + + +# --------------------------------------------------------------------------- +# Valid construction for every rule type +# --------------------------------------------------------------------------- + + +def test_daily_rule_constructs_with_defaults() -> None: + rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert rule.rule_type is RuleType.DAILY + assert rule.interval == 1 + assert rule.weekday_set is None + assert rule.day_of_month is None + assert rule.nth_weekday is None + assert rule.month is None + assert rule.end_date is None + + +def test_string_rule_type_coerces() -> None: + rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01") + assert rule.rule_type is RuleType.WEEKLY + + +def test_weekly_rule_requires_and_freezes_weekday_set() -> None: + rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01") + assert rule.weekday_set == frozenset({0, 2, 4}) + # A mutable set passed in is frozen on construction. + source = {1, 3} + rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01") + source.add(5) + assert rule2.weekday_set == frozenset({1, 3}) + + +def test_monthly_day_rule_fields() -> None: + rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01") + assert rule.day_of_month == 15 + assert rule.nth_weekday is None + + +def test_monthly_weekday_rule_fields() -> None: + rule = _rule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=2, + nth_weekday_weekday=1, + start_date="2026-09-01", + ) + assert rule.nth_weekday == 2 + assert rule.nth_weekday_weekday == 1 + + +def test_monthly_weekday_last_friday_allowed() -> None: + rule = _rule( + rule_type=RuleType.MONTHLY_WEEKDAY, + nth_weekday=-1, + nth_weekday_weekday=4, + start_date="2026-09-01", + ) + assert rule.nth_weekday == -1 + + +def test_yearly_rule_fields() -> None: + rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01") + assert rule.month == 6 + + +def test_custom_days_rule_fields() -> None: + rule = _rule( + rule_type=RuleType.CUSTOM_DAYS, + weekday_set={1, 3, 5}, + interval=2, + start_date="2026-09-01", + ) + assert rule.weekday_set == frozenset({1, 3, 5}) + assert rule.interval == 2 + + +def test_end_date_round_trips(tmp_path) -> None: + rule = _rule( + rule_type=RuleType.DAILY, + start_date="2026-09-01", + end_date="2026-12-31", + ) + assert rule.end_date == "2026-12-31" + + +# --------------------------------------------------------------------------- +# Invalid combinations are rejected at construction +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "kwargs", + [ + # weekly without weekday_set + {"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"}, + # daily carrying a weekday_set + {"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"}, + # monthly_day without day_of_month + {"rule_type": "monthly_day", "start_date": "2026-09-01"}, + # monthly_day out of range + {"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"}, + # monthly_day carrying weekday_set + {"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0}, + "start_date": "2026-09-01"}, + # yearly without month + {"rule_type": "yearly", "start_date": "2026-09-01"}, + # yearly out of range + {"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"}, + # unknown type + {"rule_type": "bogus", "start_date": "2026-09-01"}, + # interval zero + {"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"}, + # interval non-integer + {"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"}, + # end before start + {"rule_type": "daily", "start_date": "2026-09-01", + "end_date": "2026-08-31"}, + # malformed dates + {"rule_type": "daily", "start_date": "not-a-date"}, + {"rule_type": "daily", "start_date": "2026-9-1"}, + # weekly weekday out of range + {"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"}, + # weekly empty set + {"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"}, + # custom_days carrying month-specific fields + {"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5, + "start_date": "2026-09-01"}, + {"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1, + "nth_weekday_weekday": 0, "start_date": "2026-09-01"}, + {"rule_type": "custom_days", "weekday_set": {0}, "month": 3, + "start_date": "2026-09-01"}, + # monthly_weekday position invalid + {"rule_type": "monthly_weekday", "nth_weekday": 0, + "nth_weekday_weekday": 1, "start_date": "2026-09-01"}, + {"rule_type": "monthly_weekday", "nth_weekday": 6, + "nth_weekday_weekday": 1, "start_date": "2026-09-01"}, + {"rule_type": "monthly_weekday", "nth_weekday": 2, + "nth_weekday_weekday": None, "start_date": "2026-09-01"}, + {"rule_type": "monthly_weekday", "nth_weekday": 2, + "nth_weekday_weekday": 7, "start_date": "2026-09-01"}, + # monthly_day carrying nth fields + {"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1, + "nth_weekday_weekday": 0, "start_date": "2026-09-01"}, + ], +) +def test_invalid_combinations_raise(kwargs) -> None: + with pytest.raises(RuleValidationError): + _rule(**kwargs) + + +# --------------------------------------------------------------------------- +# Immutability +# --------------------------------------------------------------------------- + + +def test_rule_is_immutable() -> None: + rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01") + with pytest.raises(AttributeError): + rule.interval = 2 + with pytest.raises(AttributeError): + del rule.interval + + +# --------------------------------------------------------------------------- +# Serialization round-trip for every rule type +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "rule", + [ + ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01", + end_date="2026-12-31"), + ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2, + start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3, + start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2, + nth_weekday_weekday=1, start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1, + nth_weekday_weekday=4, interval=2, + start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None, + interval=2, start_date="2026-09-01"), + ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5}, + interval=2, start_date="2026-09-01"), + ], +) +def test_to_dict_from_dict_round_trips_losslessly(rule) -> None: + rebuilt = ScheduleRule.from_dict(rule.to_dict()) + assert rebuilt == rule + # Round-tripping twice is stable. + assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule + + +def test_from_dict_rejects_unknown_fields() -> None: + with pytest.raises(RuleValidationError, match="unknown rule fields"): + ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1}) + + +def test_from_dict_rejects_missing_rule_type() -> None: + with pytest.raises(RuleValidationError, match="missing required"): + ScheduleRule.from_dict({"interval": 1}) + + +def test_from_dict_rejects_non_dict() -> None: + with pytest.raises(RuleValidationError): + ScheduleRule.from_dict("daily") + + +def test_from_dict_defaults_start_date_when_absent() -> None: + rule = ScheduleRule.from_dict({"rule_type": "daily"}) + assert rule.start_date == "1970-01-01" + + +def test_from_dict_accepts_list_weekday_set() -> None: + rule = ScheduleRule.from_dict( + {"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]} + ) + assert rule.weekday_set == frozenset({0, 2, 4}) + + +# --------------------------------------------------------------------------- +# Equality and hashing +# --------------------------------------------------------------------------- + + +def test_equal_rules_compare_equal_and_hash_equal() -> None: + a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01") + b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01") + assert a == b + assert hash(a) == hash(b) + + +def test_different_rules_compare_unequal() -> None: + a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01") + b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01") + assert a != b + + +def test_rule_not_equal_to_other_types() -> None: + rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert rule != "daily" + assert (rule == 42) is False + + +# --------------------------------------------------------------------------- +# Purity guards: no HA imports, no database access +# --------------------------------------------------------------------------- + + +def test_recurrence_module_imports_only_stdlib() -> None: + """The model must carry no Home Assistant and no DB imports.""" + from pathlib import Path + + source = Path( + __import__("custom_components.nestquest.recurrence", + fromlist=["__file__"]).__file__ + ).read_text() + offenders = _scan_forbidden_imports(source) + assert offenders == [], ( + f"recurrence.py must not hold forbidden imports: {offenders}" + ) + + +def _scan_forbidden_imports(source: str) -> list[str]: + """The ACTUAL purity-guard logic, shared by the model scan and the + probe tests below: returns human-readable offenders (empty = clean). + + Forbidden, at any relative level, for both Import and ImportFrom: + - modules containing 'homeassistant' or 'sqlite' + - modules whose LEAF name is a DB/store/schema/DAO module + - aliases importing a DB/store/schema/DAO module by leaf name + ('from . import db', 'from custom_components.nestquest import db') + """ + import ast + + forbidden_targets = { + "db", "schema", "dao_children", "dao_rules", "dao_presence", + "dao_instances", "migrations", "store", + } + offenders: list[str] = [] + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + lowered = alias.name.lower() + leaf = lowered.split(".")[-1] + if ( + "homeassistant" in lowered + or "sqlite" in lowered + or leaf in forbidden_targets + ): + offenders.append(f"import {alias.name}") + elif isinstance(node, ast.ImportFrom): + module = (node.module or "").lower() + module_leaf = module.split(".")[-1] if module else "" + if ( + "homeassistant" in module + or "sqlite" in module + or (module and module_leaf in forbidden_targets) + or (node.level and module_leaf in forbidden_targets) + ): + offenders.append( + f"from {node.module!r} import ... (level={node.level})" + ) + for alias in node.names: + alias_leaf = alias.name.split(".")[-1].lower() + if alias_leaf in forbidden_targets: + offenders.append( + f"from {node.module!r} import {alias.name} " + f"(level={node.level})" + ) + return offenders + + +def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None: + """The shared scan function catches every forbidden import shape, + including level-0 absolute package DB imports and 'from . import db'. + """ + probes = [ + "from homeassistant.core import HomeAssistant", + "import sqlite3", + "from .db import NestQuestDatabase", + "from custom_components.nestquest.db import NestQuestDatabase", + "from custom_components.nestquest import db", + "import custom_components.nestquest.dao_rules", + "from . import db", + "from . import schema", + "from custom_components.nestquest.migrations import apply_migrations", + ] + for probe in probes: + offenders = _scan_forbidden_imports(probe) + assert offenders, f"probe must be caught: {probe!r}" + + +def test_non_string_rule_type_rejected() -> None: + """Only RuleType members and their string values construct.""" + for bad in (123, True, None, 3.5, b"daily"): + with pytest.raises(RuleValidationError): + ScheduleRule(rule_type=bad, start_date="2026-09-01") + + +def test_weekly_rejects_month_and_nth_fields() -> None: + with pytest.raises(RuleValidationError, match="must not set"): + ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, day_of_month=5, + start_date="2026-09-01", + ) + with pytest.raises(RuleValidationError, match="must not set"): + ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, month=3, + start_date="2026-09-01", + ) + + +def test_daily_rejects_dangling_nth_weekday_weekday() -> None: + with pytest.raises(RuleValidationError, match="must not set"): + ScheduleRule( + rule_type=RuleType.DAILY, nth_weekday_weekday=0, + start_date="2026-09-01", + ) + + +def test_monthly_day_rejects_month() -> None: + with pytest.raises(RuleValidationError, match="must not set"): + ScheduleRule( + rule_type=RuleType.MONTHLY_DAY, day_of_month=5, month=3, + start_date="2026-09-01", + ) + + +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", + ) + + +def test_storage_value_mapping() -> None: + """The enum exposes the schema's CHECK values via storage_value.""" + assert RuleType.DAILY.storage_value == "daily" + assert RuleType.WEEKLY.storage_value == "weekly" + assert RuleType.MONTHLY_DAY.storage_value == "monthly" + assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly" + assert RuleType.YEARLY.storage_value == "yearly" + assert RuleType.CUSTOM_DAYS.storage_value == "custom" + + +def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None: + """[0, False] must NOT collapse False into 0 via set() coercion.""" + with pytest.raises(RuleValidationError, match="plain integers"): + ScheduleRule.from_dict( + {"rule_type": "weekly", "weekday_set": [0, False]} + ) + + +def test_direct_set_with_int_subclass_entry_rejected() -> None: + """A hashable-but-non-plain int subclass is still not a plain int.""" + + class IntChild(int): + pass + + with pytest.raises(RuleValidationError, match="plain integers"): + ScheduleRule( + rule_type=RuleType.WEEKLY, + weekday_set={IntChild(1)}, + start_date="2026-09-01", + ) + + +def test_from_dict_with_int_subclass_entry_rejected() -> None: + class IntChild(int): + pass + + with pytest.raises(RuleValidationError, match="plain integers"): + ScheduleRule.from_dict( + {"rule_type": "weekly", "weekday_set": [IntChild(1)]} + ) + + +def test_from_dict_unknown_key_types_never_leak_typeerror() -> None: + """Mixed unhashable unknown keys must surface as + RuleValidationError, not a raw TypeError from sorting.""" + with pytest.raises(RuleValidationError, match="unknown rule fields"): + ScheduleRule.from_dict({"rule_type": "daily", 42: "value"}) + + +def test_storage_aliases_and_whitespace_are_not_model_names() -> None: + """The model boundary is exact names only: storage strings, padded + and case variants are all rejected.""" + for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"): + with pytest.raises(RuleValidationError): + ScheduleRule(rule_type=bad, start_date="2026-09-01") + + +def test_unhashable_int_subclass_interval_rejected() -> None: + class UnhashableInt(int): + __hash__ = None + + with pytest.raises(RuleValidationError): + ScheduleRule( + rule_type=RuleType.DAILY, + interval=UnhashableInt(1), + start_date="2026-09-01", + ) + + +def test_rule_type_is_a_dataclass() -> None: + import dataclasses + + assert dataclasses.is_dataclass(ScheduleRule) -- 2.55.0 From 3b638cae6faa08ae5b20b9067f6b78dd52300c20 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 11:00:45 -0400 Subject: [PATCH 02/10] Implement daily recurrence with interval support (#26) --- custom_components/nestquest/recurrence.py | 89 +++++++ tests/test_recurrence_daily.py | 293 ++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 tests/test_recurrence_daily.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index ba7f605..d0eba90 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -387,3 +387,92 @@ class ScheduleRule: start_date=data.get("start_date", "1970-01-01"), end_date=data.get("end_date"), ) + + +# --------------------------------------------------------------------------- +# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule +# --------------------------------------------------------------------------- + + +def _parse_date(value: str) -> datetime.date: + """Parse a strict ISO date already validated by the model.""" + return datetime.datetime.strptime(value, _DATE_FORMAT).date() + + +def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int: + """Day offset from the rule's anchor date to ``target``. + + The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the + first day the rule CAN fire. A negative offset means the target + precedes the rule's window entirely. + """ + return (target - _parse_date(rule.start_date)).days + + +def _within_window(rule: ScheduleRule, target: datetime.date) -> bool: + """True when ``target`` is inside the rule's [start, end] window.""" + if target < _parse_date(rule.start_date): + return False + if rule.end_date is not None and target > _parse_date(rule.end_date): + return False + return True + + +def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: + """Return True when ``rule`` fires on ``target``. + + Pure function: no I/O, no timezone awareness, no mutation. Dates + are plain calendar dates (Feature 04 guardrail); the caller decides + the timezone that produced them. + """ + # Dispatch FIRST: an unimplemented shape must raise loudly even for + # dates outside the window, never silently evaluate to False. + shape = rule.rule_type + if shape is not RuleType.DAILY: + raise NotImplementedError( + f"occurs_on for {shape} lands with its own engine task " + "(Features 04 task sequence)" + ) + if not _within_window(rule, target): + return False + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 + + +def occurrences_between( + rule: ScheduleRule, start: str, end: str +) -> list[str]: + """Return every date string in [start, end] the rule fires on. + + Inclusive on both ends. Raises RuleValidationError on malformed or + inverted bounds; a window entirely outside [rule.start_date, + rule.end_date] yields an empty list. + """ + if rule.rule_type is not RuleType.DAILY: + raise NotImplementedError( + f"occurrences_between for {rule.rule_type} lands with its own " + "engine task (Features 04 task sequence)" + ) + _validate_date(start, "start") + _validate_date(end, "end") + start_date = _parse_date(start) + end_date = _parse_date(end) + if end_date < start_date: + raise RuleValidationError( + f"end {end!r} must be on or after start {start!r}" + ) + rule_start = _parse_date(rule.start_date) + if end_date < rule_start: + return [] + # Clamp the walk to the rule's own window and the queried range. + effective_start = max(start_date, rule_start) + effective_end = end_date + if rule.end_date is not None: + effective_end = min(effective_end, _parse_date(rule.end_date)) + results: list[str] = [] + cursor = effective_start + while cursor <= effective_end: + if occurs_on(rule, cursor): + results.append(cursor.isoformat()) + cursor += datetime.timedelta(days=1) + return results diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py new file mode 100644 index 0000000..a4ab4e0 --- /dev/null +++ b/tests/test_recurrence_daily.py @@ -0,0 +1,293 @@ +"""Tests for the daily recurrence evaluation (occurs_on/occurrences_between).""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, + occurs_on, + occurrences_between, +) + + +def _d(date: str) -> datetime.date: + return datetime.date.fromisoformat(date) + + +def test_daily_fires_every_day_from_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert occurs_on(rule, _d("2026-09-01")) is True + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-09-30")) is True + + +def test_daily_never_fires_before_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + assert occurs_on(rule, _d("2026-08-31")) is False + + +def test_daily_respects_end_date() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-10", + ) + assert occurs_on(rule, _d("2026-09-10")) is True + assert occurs_on(rule, _d("2026-09-11")) is False + + +def test_daily_interval_2_fires_every_second_day() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + # Odd offsets fire (0, 2, 4...); odd offsets don't. + assert occurs_on(rule, _d("2026-09-01")) is True # offset 0 + assert occurs_on(rule, _d("2026-09-02")) is False # offset 1 + assert occurs_on(rule, _d("2026-09-03")) is True # offset 2 + assert occurs_on(rule, _d("2026-09-04")) is False # offset 3 + assert occurs_on(rule, _d("2026-09-05")) is True # offset 4 + + +def test_daily_interval_3_fires_every_third_day() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01" + ) + assert occurs_on(rule, _d("2026-09-01")) is True # 0 + assert occurs_on(rule, _d("2026-09-02")) is False # 1 + assert occurs_on(rule, _d("2026-09-03")) is False # 2 + assert occurs_on(rule, _d("2026-09-04")) is True # 3 + assert occurs_on(rule, _d("2026-09-07")) is True # 6 + + +def test_hand_checked_30_day_sequence_interval_1() -> None: + """The done-condition's hand-checked sequence: 30 days, all fire.""" + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + expected = [ + (2026, 9, day) for day in range(1, 31) + ] + for (year, month, day) in expected: + assert occurs_on(rule, datetime.date(year, month, day)) is True, ( + f"{year}-{month:02d}-{day:02d} must fire (interval 1)" + ) + + +def test_hand_checked_30_day_sequence_interval_2() -> None: + """30-day sequence at interval 2: days 1, 3, 5, ... (odd days).""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + for day in range(1, 31): + fired = occurs_on(rule, datetime.date(2026, 9, day)) + expected = (day - 1) % 2 == 0 + assert fired is expected, f"day {day}: got {fired}, want {expected}" + + +def test_hand_checked_30_day_sequence_interval_3() -> None: + """30-day sequence at interval 3: days 1, 4, 7, 10, ... 28.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01" + ) + fired_days = [ + day + for day in range(1, 31) + if occurs_on(rule, datetime.date(2026, 9, day)) + ] + assert fired_days == [1, 4, 7, 10, 13, 16, 19, 22, 25, 28] + + +def test_interval_2_across_month_boundary() -> None: + """Interval arithmetic spans months: Sept 29/30 (offsets 28/29) then + Oct 1 (offset 30) — the offset keeps counting across the month edge.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + assert occurs_on(rule, _d("2026-09-29")) is True # offset 28 + assert occurs_on(rule, _d("2026-09-30")) is False # offset 29 + assert occurs_on(rule, _d("2026-10-01")) is True # offset 30 + assert occurs_on(rule, _d("2026-10-02")) is False # offset 31 + + +def test_interval_2_across_year_boundary() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30" + ) + assert occurs_on(rule, _d("2026-12-30")) is True # 0 + assert occurs_on(rule, _d("2026-12-31")) is False # 1 + assert occurs_on(rule, _d("2027-01-01")) is True # 2 + + +# --------------------------------------------------------------------------- +# occurrences_between +# --------------------------------------------------------------------------- + + +def test_occurrences_between_daily_interval_1() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + result = occurrences_between(rule, "2026-09-05", "2026-09-08") + assert result == [ + "2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08", + ] + + +def test_occurrences_between_daily_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + result = occurrences_between(rule, "2026-09-01", "2026-09-10") + assert result == [ + "2026-09-01", "2026-09-03", "2026-09-05", "2026-09-07", "2026-09-09", + ] + + +def test_occurrences_between_clamps_to_rule_start() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-05") + result = occurrences_between(rule, "2026-09-01", "2026-09-08") + assert result == ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"] + + +def test_occurrences_between_clamps_to_rule_end() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-06", + ) + result = occurrences_between(rule, "2026-09-01", "2026-09-20") + assert result == [ + f"2026-09-{day:02d}" for day in range(1, 7) + ] + + +def test_occurrences_between_disjoint_range_is_empty() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, start_date="2026-09-01", + end_date="2026-09-15", + ) + # The rule ends Sept 15: October is entirely outside its window. + assert occurrences_between(rule, "2026-10-01", "2026-10-05") == [] + assert occurrences_between(rule, "2026-08-01", "2026-08-05") == [] + + +def test_occurrences_between_inclusive_bounds() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01" + ) + # Both boundary dates fire at interval 2 (offsets 0 and 6). + result = occurrences_between(rule, "2026-09-01", "2026-09-07") + assert result[0] == "2026-09-01" + assert result[-1] == "2026-09-07" + + +def test_occurrences_between_rejects_inverted_range() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + with pytest.raises(RuleValidationError, match="on or after"): + occurrences_between(rule, "2026-09-10", "2026-09-05") + + +def test_occurrences_between_rejects_malformed_dates() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01") + with pytest.raises(RuleValidationError, match="YYYY-MM-DD"): + occurrences_between(rule, "oops", "2026-09-05") + with pytest.raises(RuleValidationError, match="YYYY-MM-DD"): + occurrences_between(rule, "2026-09-05", "2026-9-5") + + +def test_occurrences_between_leap_day_fires() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-02-27") + # 2028 is a leap year: Feb 29 exists and is 2 days after Feb 27. + result = occurrences_between(rule, "2028-02-28", "2028-03-01") + assert result == ["2028-02-28", "2028-02-29", "2028-03-01"] + + +def test_occurrences_between_year_boundary() -> None: + rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-12-30") + result = occurrences_between(rule, "2026-12-30", "2027-01-02") + assert result == [ + "2026-12-30", "2026-12-31", "2027-01-01", "2027-01-02", + ] + + +# --------------------------------------------------------------------------- +# Purity: the engine raises on unknown shapes rather than guessing +# --------------------------------------------------------------------------- + + +def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None: + """Weekly and beyond land with their own engine tasks; a loud + NotImplementedError beats a silent wrong answer.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + ) + with pytest.raises(NotImplementedError): + occurs_on(rule, _d("2026-09-07")) + +def test_interval_7_preserves_weekday_alignment_across_months() -> None: + """Weekly-by-interval semantics: every firing lands on the SAME + weekday as start_date, no matter how many month edges intervene.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01" + ) + # 2026-09-01 is a Tuesday; every firing must be a Tuesday too. + assert _d("2026-09-01").weekday() == 1 + for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"): + assert occurs_on(rule, _d(day)) is True, day + assert _d(day).weekday() == 1, f"{day} must be a Tuesday" + # The day after a firing is never a firing. + assert occurs_on(rule, _d("2026-10-07")) is False + + +def test_interval_2_across_leap_day() -> None: + """Interval > 1 arithmetic across a leap day: the extra day shifts + subsequent offsets by one, and the mod arithmetic reflects it.""" + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27" + ) + assert occurs_on(rule, _d("2028-02-27")) is True # 0 + assert occurs_on(rule, _d("2028-02-28")) is False # 1 + assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day) + assert occurs_on(rule, _d("2028-03-01")) is False # 3 + assert occurs_on(rule, _d("2028-03-02")) is True # 4 + + +def test_occurrences_between_leap_day_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27" + ) + result = occurrences_between(rule, "2028-02-27", "2028-03-03") + assert result == ["2028-02-27", "2028-02-29", "2028-03-02"] + + +def test_occurrences_between_year_boundary_interval_2() -> None: + rule = ScheduleRule( + rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30" + ) + result = occurrences_between(rule, "2026-12-30", "2027-01-04") + # Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are + # Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets: + assert result == ["2026-12-30", "2027-01-01", "2027-01-03"] + + +def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None: + """An unimplemented shape must raise whether the date is inside or + outside the window — silent False would hide a missing engine.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, + start_date="2026-09-01", end_date="2026-09-10", + ) + # Before start, inside window, after end: all must raise. + for day in ("2026-08-31", "2026-09-07", "2026-09-20"): + with pytest.raises(NotImplementedError): + occurs_on(rule, _d(day)) + + +def test_occurrences_between_unimplemented_shape_raises_even_disjoint( + tmp_path, +) -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + ) + # A range entirely before the rule's start still raises: the shape + # is unimplemented, and silence would hide it. + with pytest.raises(NotImplementedError): + occurrences_between(rule, "2026-08-01", "2026-08-10") -- 2.55.0 From 23a52707af29d16799c659e2658089f20d6025ca Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 11:10:39 -0400 Subject: [PATCH 03/10] Implement weekly and custom day-set recurrence (#27) --- custom_components/nestquest/recurrence.py | 23 +- tests/test_recurrence_daily.py | 12 +- tests/test_recurrence_weekly.py | 299 ++++++++++++++++++++++ 3 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 tests/test_recurrence_weekly.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index d0eba90..7c71458 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -428,15 +428,28 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: # Dispatch FIRST: an unimplemented shape must raise loudly even for # dates outside the window, never silently evaluate to False. shape = rule.rule_type - if shape is not RuleType.DAILY: + if shape not in (RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS): raise NotImplementedError( f"occurs_on for {shape} lands with its own engine task " "(Features 04 task sequence)" ) if not _within_window(rule, target): return False - offset = (target - _parse_date(rule.start_date)).days - return offset % rule.interval == 0 + if shape is RuleType.DAILY: + offset = (target - _parse_date(rule.start_date)).days + return offset % rule.interval == 0 + # WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never + # ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO + # parity on January 1st; anchor arithmetic does not). + anchor = _parse_date(rule.start_date) + if shape is RuleType.CUSTOM_DAYS: + interval = 1 + else: + interval = rule.interval + week_offset = (target - anchor).days // 7 + if week_offset % interval != 0: + return False + return target.weekday() in rule.weekday_set def occurrences_between( @@ -448,7 +461,9 @@ def occurrences_between( inverted bounds; a window entirely outside [rule.start_date, rule.end_date] yields an empty list. """ - if rule.rule_type is not RuleType.DAILY: + if rule.rule_type not in ( + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS + ): raise NotImplementedError( f"occurrences_between for {rule.rule_type} lands with its own " "engine task (Features 04 task sequence)" diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index a4ab4e0..ed8fada 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -214,13 +214,14 @@ def test_occurrences_between_year_boundary() -> None: def test_occurs_on_rejects_unimplemented_shapes_loudly() -> None: - """Weekly and beyond land with their own engine tasks; a loud + """Monthly/yearly land with their own engine tasks; a loud NotImplementedError beats a silent wrong answer.""" rule = ScheduleRule( - rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + start_date="2026-09-01", ) with pytest.raises(NotImplementedError): - occurs_on(rule, _d("2026-09-07")) + occurs_on(rule, _d("2026-09-15")) def test_interval_7_preserves_weekday_alignment_across_months() -> None: """Weekly-by-interval semantics: every firing lands on the SAME @@ -272,7 +273,7 @@ def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None: """An unimplemented shape must raise whether the date is inside or outside the window — silent False would hide a missing engine.""" rule = ScheduleRule( - rule_type=RuleType.WEEKLY, weekday_set={0}, + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01", end_date="2026-09-10", ) # Before start, inside window, after end: all must raise. @@ -285,7 +286,8 @@ def test_occurrences_between_unimplemented_shape_raises_even_disjoint( tmp_path, ) -> None: rule = ScheduleRule( - rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + start_date="2026-09-01" ) # A range entirely before the rule's start still raises: the shape # is unimplemented, and silence would hide it. diff --git a/tests/test_recurrence_weekly.py b/tests/test_recurrence_weekly.py new file mode 100644 index 0000000..10bd181 --- /dev/null +++ b/tests/test_recurrence_weekly.py @@ -0,0 +1,299 @@ +"""Tests for weekly and custom day-set recurrence evaluation.""" +from __future__ import annotations + +import datetime + +import pytest + +from custom_components.nestquest.recurrence import ( + RuleType, + RuleValidationError, + ScheduleRule, + occurs_on, + occurrences_between, +) + + +def _d(date: str) -> datetime.date: + return datetime.date.fromisoformat(date) + + +# 2026-09-01 is a TUESDAY (weekday 1). +# Anchor-week arithmetic (start_date = Tue Sep 1): week 0 spans +# Sep 1-7, week 1 spans Sep 8-14, week 2 spans Sep 15-21, and so on — +# whole weeks elapsed from the anchor, NOT calendar-Sunday weeks. +# A weekly Mon/Wed/Fri (0,2,4) rule at interval 1 anchored here fires +# on Wed Sep 2, Fri Sep 4 (both week 0), then Mon Sep 7, Wed Sep 9, +# Fri Sep 11 (week 1), Mon Sep 14... (week 2), and so on. + + +def test_weekly_fires_exactly_on_set_weekdays() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", + ) + # Sep 2026: 1=Tue(1), 2=Wed(2), 4=Fri(4), 7=Mon(0), 8=Tue(1), 9=Wed, + # 10=Thu(3), 11=Fri(4) + assert occurs_on(rule, _d("2026-09-01")) is False # Tue NOT in set + assert occurs_on(rule, _d("2026-09-02")) is True # Wed in set + assert occurs_on(rule, _d("2026-09-03")) is False # Thu not in set + assert occurs_on(rule, _d("2026-09-04")) is True # Fri in set + assert occurs_on(rule, _d("2026-09-05")) is False # Sat + assert occurs_on(rule, _d("2026-09-06")) is False # Sun + assert occurs_on(rule, _d("2026-09-07")) is True # Mon + assert occurs_on(rule, _d("2026-09-08")) is False # Tue not in set + + +def test_weekly_start_date_itself_may_not_be_in_set() -> None: + """start_date anchors the WEEK count, not a firing day: a Tuesday + start for a Mon/Wed/Fri rule fires on Wed Sep 2, not Sep 1.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", + ) + assert occurs_on(rule, _d("2026-09-01")) is False # Tue=1, not in set + assert occurs_on(rule, _d("2026-09-02")) is True # Wed=2 + assert occurs_on(rule, _d("2026-09-04")) is True # Fri=4 + assert occurs_on(rule, _d("2026-09-07")) is True # Mon, next week + + +def test_weekly_never_fires_before_start() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={2}, # Wednesdays + start_date="2026-09-02", # a Wednesday + ) + # Wed Sep 2 is the anchor day and in-set: fires. Wed Aug 26 (one + # week BEFORE the anchor, in-set) must not fire: pre-window. + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-08-26")) is False # Wed, before start + assert occurs_on(rule, _d("2026-09-09")) is True # Wed, next week + + +def test_weekly_respects_end_date() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", end_date="2026-09-10", + ) + assert occurs_on(rule, _d("2026-09-09")) is True # Wed within end + assert occurs_on(rule, _d("2026-09-11")) is False # Fri after end + + +def test_weekly_interval_2_fires_on_alternate_weeks() -> None: + """Interval 2: fires on set weekdays only in even week-offsets.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", # a Monday + ) + # Week 0 (Sep 7-13): fires Mon Sep 7. + assert occurs_on(rule, _d("2026-09-07")) is True + # Week 1 (Sep 14-20): skipped. + assert occurs_on(rule, _d("2026-09-14")) is False + # Week 2 (Sep 21-27): fires Mon Sep 21. + assert occurs_on(rule, _d("2026-09-21")) is True + # Week 3 (Sep 28-Oct 4): skipped; Mon Sep 28 does not fire. + assert occurs_on(rule, _d("2026-09-28")) is False + # Week 4 (Oct 5-11): fires Mon Oct 5. + assert occurs_on(rule, _d("2026-10-05")) is True + + +def test_weekly_interval_3_every_third_week() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={3}, interval=3, + start_date="2026-09-03", # a Thursday + ) + assert occurs_on(rule, _d("2026-09-03")) is True # week 0 + assert occurs_on(rule, _d("2026-09-10")) is False # week 1 + assert occurs_on(rule, _d("2026-09-17")) is False # week 2 + assert occurs_on(rule, _d("2026-09-24")) is True # week 3 + assert occurs_on(rule, _d("2026-10-15")) is True # week 6 + + +def test_weekly_interval_2_never_fires_in_odd_weeks_across_months() -> None: + """The week offset is anchored to start_date across month edges.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={2}, interval=2, + start_date="2026-09-02", # a Wednesday + ) + # Week 0: Wed Sep 2 fires. Week 1: Wed Sep 9 skipped. + # Week 2: Wed Sep 16 fires. Week 3: Wed Sep 23 skipped. + # Week 4 (Oct): Wed Sep 30 fires (still week 4!). Week 5: Wed Oct 7 skipped. + assert occurs_on(rule, _d("2026-09-02")) is True + assert occurs_on(rule, _d("2026-09-09")) is False + assert occurs_on(rule, _d("2026-09-16")) is True + assert occurs_on(rule, _d("2026-09-23")) is False + assert occurs_on(rule, _d("2026-09-30")) is True + assert occurs_on(rule, _d("2026-10-07")) is False + assert occurs_on(rule, _d("2026-10-14")) is True + + +def test_custom_days_interval_1_behaves_like_weekly() -> None: + """CUSTOM_DAYS with interval 1 behaves identically to WEEKLY.""" + weekly = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5}, + start_date="2026-09-01", + ) + custom = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5}, + start_date="2026-09-01", + ) + # Whole month of September must match exactly. + for day in range(1, 31): + date = datetime.date(2026, 9, day) + assert occurs_on(weekly, date) == occurs_on(custom, date), date + + +def test_custom_days_never_fires_before_start(tmp_path) -> None: + rule = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={0}, + start_date="2026-09-07", # a Monday + ) + assert occurs_on(rule, _d("2026-09-06")) is False # Sun, before + assert occurs_on(rule, _d("2026-09-07")) is True # Mon, on + + +def test_hand_checked_8_week_sequence_weekly_mon_wed_fri() -> None: + """The done-condition's hand-checked sequence: 8 weeks of a + Mon/Wed/Fri rule anchored at Tue 2026-09-01, interval 1. + + Anchor weeks from Tue Sep 1 (week 0 = Sep 1-7, week 1 = Sep 8-14, + ... week 7 = Oct 20-26). Mon/Wed/Fri firings (interval 1, so every + in-set weekday fires regardless of week): + Week 0 (Sep 1-7): Wed 2, Fri 4 (Mon Sep 7 is ALSO week 0: + 6 elapsed days // 7 = 0 — the anchor week + boundary sits on Sep 8, not on the Monday) + Week 1 (Sep 8-14): Wed 9, Fri 11, Mon 14 (13 elapsed days // 7 = 1) + Week 2 (Sep 15-21): Wed 16, Fri 18, Mon 21 + Week 3 (Sep 22-28): Wed 23, Fri 25, Mon 28 + Week 4 (Sep 29-Oct 5): Wed 30, Fri 2 (Oct), Mon 5 (Oct) + Week 5 (Oct 6-12): Wed 7, Fri 9, Mon 12 + Week 6 (Oct 13-19): Wed 14, Fri 16, Mon 19 + Week 7 (Oct 20-26): Wed 21, Fri 23, Mon 26 + """ + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, + start_date="2026-09-01", end_date="2026-10-26", + ) + expected = [ + "2026-09-02", "2026-09-04", + "2026-09-07", "2026-09-09", "2026-09-11", + "2026-09-14", "2026-09-16", "2026-09-18", + "2026-09-21", "2026-09-23", "2026-09-25", + "2026-09-28", "2026-09-30", "2026-10-02", + "2026-10-05", "2026-10-07", "2026-10-09", + "2026-10-12", "2026-10-14", "2026-10-16", + "2026-10-19", "2026-10-21", "2026-10-23", "2026-10-26", + ] + actual = occurrences_between(rule, "2026-09-01", "2026-10-26") + assert actual == expected, ( + f"diff: {set(expected) ^ set(actual)}" + ) + + +def test_hand_checked_8_week_sequence_weekly_interval_2_mondays() -> None: + """8 weeks of a Monday rule at interval 2, anchored Mon 2026-09-07 + (week 0 = Sep 7-13): fires in weeks 0, 2, 4, 6 -> Mon Sep 7, Mon Sep 21, + Mon Oct 5, Mon Oct 19.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", end_date="2026-10-31", + ) + actual = occurrences_between(rule, "2026-09-07", "2026-10-31") + assert actual == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"] + + +def test_custom_days_hand_checked_8_week_sequence() -> None: + """8 weeks of a Tue/Thu/Sun rule (1,3,6) anchored Mon 2026-09-07: + fires Tue 8, Thu 10, Sun 13, then weekly.""" + rule = ScheduleRule( + rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 6}, + start_date="2026-09-07", + ) + # Anchor weeks from Mon Sep 7 (w0 = Sep 7-13 ... w7 = Oct 26-Nov 1): + # Tue/Thu/Sun firings per week, 8 weeks fully covered. + actual = occurrences_between(rule, "2026-09-07", "2026-11-01") + assert actual == [ + "2026-09-08", "2026-09-10", "2026-09-13", + "2026-09-15", "2026-09-17", "2026-09-20", + "2026-09-22", "2026-09-24", "2026-09-27", + "2026-09-29", "2026-10-01", "2026-10-04", + "2026-10-06", "2026-10-08", "2026-10-11", + "2026-10-13", "2026-10-15", "2026-10-18", + "2026-10-20", "2026-10-22", "2026-10-25", + "2026-10-27", "2026-10-29", "2026-11-01", + ] + + +def test_weekly_survives_53_week_year_boundary() -> None: + """Anchor arithmetic never flips at Jan 1 (the ISO parity trap): + a weekly rule spanning New Year keeps its weekday pattern.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={3}, # Thursdays + start_date="2026-12-24", # a Thursday + ) + # Thursdays around New Year 2026->2027: Dec 24, 31, Jan 7, 14. + assert occurs_on(rule, _d("2026-12-24")) is True + assert occurs_on(rule, _d("2026-12-31")) is True + assert occurs_on(rule, _d("2027-01-07")) is True + assert occurs_on(rule, _d("2027-01-14")) is True + # Interleaved days are not Thursdays. + assert occurs_on(rule, _d("2027-01-04")) is False # Monday + assert occurs_on(rule, _d("2027-01-05")) is False # Tuesday + + +def test_weekly_interval_2_across_year_boundary() -> None: + """Interval weeks continue across Jan 1 with no parity flip.""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={4}, interval=2, + start_date="2026-12-25", # a Friday + ) + # Weeks: Dec 25 (w0) fires, Jan 1 (w1) skipped, Jan 8 (w2) fires, + # Jan 15 (w3) skipped, Jan 22 (w4) fires. + assert occurs_on(rule, _d("2026-12-25")) is True + assert occurs_on(rule, _d("2027-01-01")) is False + assert occurs_on(rule, _d("2027-01-08")) is True + assert occurs_on(rule, _d("2027-01-15")) is False + assert occurs_on(rule, _d("2027-01-22")) is True + + +def test_occurrences_between_weekly_disjoint(tmp_path) -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={1}, + start_date="2026-09-01", end_date="2026-09-30", + ) + assert occurrences_between(rule, "2026-10-01", "2026-10-31") == [] + + +def test_occurrences_between_weekly_interval_2_span() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-07", end_date="2026-10-31", + ) + result = occurrences_between(rule, "2026-09-01", "2026-10-31") + assert result == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"] + + +def test_occurrences_between_rejects_inverted_bounds() -> None: + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01" + ) + with pytest.raises(RuleValidationError, match="on or after"): + occurrences_between(rule, "2026-10-10", "2026-09-01") + +def test_weekly_interval_2_non_monday_anchor_boundary() -> None: + """The anchor week is anchored at start_date, NOT a calendar week: + a Tuesday-anchored Monday rule fires on Mon Sep 7 (week 0 extends + through Sep 7), skips Mon Sep 14 (week 1), fires Mon Sep 21 (week 2).""" + rule = ScheduleRule( + rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2, + start_date="2026-09-01", # a TUESDAY anchor + ) + # Sep 7 - Sep 1 = 6 days -> week 0 (even): fires. + assert occurs_on(rule, _d("2026-09-07")) is True + # Sep 14: 13 days -> week 1 (odd): skipped. + assert occurs_on(rule, _d("2026-09-14")) is False + # Sep 21: 20 days -> week 2 (even): fires. + assert occurs_on(rule, _d("2026-09-21")) is True + # Sep 28: 27 days -> week 3 (odd): skipped. + assert occurs_on(rule, _d("2026-09-28")) is False + # Oct 5: 34 days -> week 4 (even): fires. + assert occurs_on(rule, _d("2026-10-05")) is True \ No newline at end of file -- 2.55.0 From d81f38528a900480fff5ba22d7629903bd0e27ab Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 11:15:49 -0400 Subject: [PATCH 04/10] Implement monthly day-of-month recurrence with month-end policy (#28) --- custom_components/nestquest/recurrence.py | 59 ++++- tests/test_recurrence_daily.py | 6 +- tests/test_recurrence_monthly_day.py | 301 ++++++++++++++++++++++ 3 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 tests/test_recurrence_monthly_day.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index 7c71458..b759ec0 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -39,11 +39,16 @@ reach storage): - ``start_date`` is a strict ISO date; ``end_date`` is optional and, when present, on or after ``start_date``. -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. +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. """ from __future__ import annotations @@ -418,6 +423,40 @@ 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 occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: """Return True when ``rule`` fires on ``target``. @@ -428,7 +467,10 @@ 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): + if shape not in ( + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, + ): raise NotImplementedError( f"occurs_on for {shape} lands with its own engine task " "(Features 04 task sequence)" @@ -438,6 +480,8 @@ 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) # 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). @@ -462,7 +506,8 @@ def occurrences_between( rule.end_date] yields an empty list. """ if rule.rule_type not in ( - RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS + RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, + RuleType.MONTHLY_DAY, ): raise NotImplementedError( f"occurrences_between for {rule.rule_type} lands with its own " diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index ed8fada..b61fbba 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -217,7 +217,7 @@ 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, + rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01", ) with pytest.raises(NotImplementedError): @@ -273,7 +273,7 @@ def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None: """An unimplemented shape must raise whether the date is inside or outside the window — silent False would hide a missing engine.""" rule = ScheduleRule( - rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01", end_date="2026-09-10", ) # Before start, inside window, after end: all must raise. @@ -286,7 +286,7 @@ def test_occurrences_between_unimplemented_shape_raises_even_disjoint( tmp_path, ) -> None: rule = ScheduleRule( - rule_type=RuleType.MONTHLY_DAY, day_of_month=15, + rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01" ) # A range entirely before the rule's start still raises: the shape diff --git a/tests/test_recurrence_monthly_day.py b/tests/test_recurrence_monthly_day.py new file mode 100644 index 0000000..aec763c --- /dev/null +++ b/tests/test_recurrence_monthly_day.py @@ -0,0 +1,301 @@ +"""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 \ No newline at end of file -- 2.55.0 From db4f695541ed7f44f5997a8778c46dbdbda9b0dc Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 14:35:53 -0400 Subject: [PATCH 05/10] Implement monthly nth-weekday recurrence (#29) --- custom_components/nestquest/recurrence.py | 53 +++- tests/test_recurrence_monthly_weekday.py | 324 ++++++++++++++++++++++ 2 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 tests/test_recurrence_monthly_weekday.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index b759ec0..5a72293 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -457,6 +457,53 @@ def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool: 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: """Return True when ``rule`` fires on ``target``. @@ -469,7 +516,7 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: shape = rule.rule_type if shape not in ( RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, ): raise NotImplementedError( 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 if shape is RuleType.MONTHLY_DAY: 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 # ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO # parity on January 1st; anchor arithmetic does not). @@ -507,7 +556,7 @@ def occurrences_between( """ if rule.rule_type not in ( RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, ): raise NotImplementedError( f"occurrences_between for {rule.rule_type} lands with its own " diff --git a/tests/test_recurrence_monthly_weekday.py b/tests/test_recurrence_monthly_weekday.py new file mode 100644 index 0000000..1d08be7 --- /dev/null +++ b/tests/test_recurrence_monthly_weekday.py @@ -0,0 +1,324 @@ +"""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") \ No newline at end of file -- 2.55.0 From 5a962e0ed77506fb4a45dd0755535e61a8487608 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 14:39:52 -0400 Subject: [PATCH 06/10] Implement yearly recurrence with leap-day policy (#30) --- custom_components/nestquest/recurrence.py | 42 +++- tests/test_recurrence_daily.py | 56 +++--- tests/test_recurrence_model.py | 15 +- tests/test_recurrence_yearly.py | 233 ++++++++++++++++++++++ 4 files changed, 306 insertions(+), 40 deletions(-) create mode 100644 tests/test_recurrence_yearly.py diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index 5a72293..c114568 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -270,10 +270,9 @@ class ScheduleRule: "nth_weekday_weekday", "month") elif shape is RuleType.MONTHLY_WEEKDAY: forbidden_fields = ("weekday_set", "day_of_month", "month") - else: # YEARLY + elif shape is RuleType.YEARLY: forbidden_fields = ( "weekday_set", - "day_of_month", "nth_weekday", "nth_weekday_weekday", ) @@ -316,6 +315,14 @@ 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). @@ -504,6 +511,31 @@ def _occurs_monthly_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``. @@ -516,7 +548,7 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: shape = rule.rule_type if shape not in ( RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, ): raise NotImplementedError( f"occurs_on for {shape} lands with its own engine task " @@ -531,6 +563,8 @@ def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool: 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). @@ -556,7 +590,7 @@ def occurrences_between( """ if rule.rule_type not in ( RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS, - RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, + RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY, ): raise NotImplementedError( f"occurrences_between for {rule.rule_type} lands with its own " diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index b61fbba..4505d38 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -213,15 +213,32 @@ def test_occurrences_between_year_boundary() -> None: # --------------------------------------------------------------------------- -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.YEARLY, month=6, - start_date="2026-09-01", +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"} ) - with pytest.raises(NotImplementedError): - occurs_on(rule, _d("2026-09-15")) + 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") def test_interval_7_preserves_weekday_alignment_across_months() -> None: """Weekly-by-interval semantics: every firing lands on the SAME @@ -269,27 +286,4 @@ 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.YEARLY, month=6, - 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.YEARLY, month=6, - 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") diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 5d0def5..8e12098 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -393,16 +393,21 @@ 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: diff --git a/tests/test_recurrence_yearly.py b/tests/test_recurrence_yearly.py new file mode 100644 index 0000000..d544f39 --- /dev/null +++ b/tests/test_recurrence_yearly.py @@ -0,0 +1,233 @@ +"""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 \ No newline at end of file -- 2.55.0 From 4230c1d0d4b5c5e0222a450ff8c4d357e996bef4 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 14:49:46 -0400 Subject: [PATCH 07/10] Enforce start_date and end_date bounds across all rule types (#31) --- tests/test_recurrence_bounds.py | 361 ++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/test_recurrence_bounds.py diff --git a/tests/test_recurrence_bounds.py b/tests/test_recurrence_bounds.py new file mode 100644 index 0000000..2098d24 --- /dev/null +++ b/tests/test_recurrence_bounds.py @@ -0,0 +1,361 @@ +"""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 == [] \ No newline at end of file -- 2.55.0 From b4c9ad35c36e78eaafe637c7223b3b8236fd56b8 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 14:54:26 -0400 Subject: [PATCH 08/10] Implement occurrences_between range expansion (#32) --- tests/test_recurrence_property.py | 241 ++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tests/test_recurrence_property.py diff --git a/tests/test_recurrence_property.py b/tests/test_recurrence_property.py new file mode 100644 index 0000000..eb2ef04 --- /dev/null +++ b/tests/test_recurrence_property.py @@ -0,0 +1,241 @@ +"""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) -- 2.55.0 From 8c99024dd0818b71dce98b103096d8c849188590 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Mon, 14 Sep 2026 15:01:02 -0400 Subject: [PATCH 09/10] Full recurrence test suite across year boundaries and leap years (#33) --- tests/test_recurrence_consolidated.py | 444 ++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 tests/test_recurrence_consolidated.py diff --git a/tests/test_recurrence_consolidated.py b/tests/test_recurrence_consolidated.py new file mode 100644 index 0000000..441cc7d --- /dev/null +++ b/tests/test_recurrence_consolidated.py @@ -0,0 +1,444 @@ +"""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 \ No newline at end of file -- 2.55.0 From 21948fdee462ed542cf632aaae265b3b914e1180 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 15:02:21 -0400 Subject: [PATCH 10/10] release: advance version to 0.3.0 and add Feature 04 release notes --- RELEASE_NOTES.md | 43 +++++++++++++++++++++++ custom_components/nestquest/manifest.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8b7a8aa..6de8b0c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,48 @@ # NestQuest Release Notes +## Version 0.3.0 — 2026-09-14 + +### Scope + +Third release of NestQuest, shipping Feature 04 — the recurrence rule engine: + +- `ScheduleRule`: an immutable, validated rule model (frozen dataclass) covering six recurrence shapes — daily, weekly (weekday sets), monthly by day-of-month, monthly by nth-weekday, yearly, and custom day-sets — with interval multipliers (every N days/weeks/months) and optional start/end dates. +- `occurs_on(rule, date)`: pure calendar evaluation answering "does this rule fire on this date?" +- `occurrences_between(rule, start, end)`: ordered, duplicate-free date lists, inclusive bounds, clamped to the rule's window. +- Documented month-end policy: a rule for a day absent from a month (the 31st in April, the 30th/29th in February) fires on that month's LAST day — one firing per eligible month, interval anchored on nominal months elapsed. +- Documented leap-day policy: a YEARLY February 29th rule fires February 28th in non-leap years (clamp, never skip). +- Week offsets anchored at `start_date` by whole weeks elapsed — never ISO week numbers or parity (53-week years cannot silently invert schedules). +- The engine is pure: no Home Assistant imports, no database access, no timezone logic. + +### Requirements + +- Minimum Home Assistant version: **2024.6.0**. + +### Known Limitations + +- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release (data layer + recurrence engine only). +- No business logic yet: nothing materializes task instances (Feature 07 combines this engine with presence schedules) — storage and scheduling math only. +- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance. + +### Rollback + +Downgrading from 0.3.0 to 0.2.0 removes the recurrence engine's code but leaves the `nestquest.db` file in place (untouched by both releases; no code path deletes an existing database). The file's schema version stamp is unaffected — this release ships no schema migration. + +**HACS installs:** + +1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.2.0) or Remove entirely. +2. Restart Home Assistant. +3. If removing: delete the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again. + +**Manual installs:** + +1. Replace or delete the `custom_components/nestquest/` directory in your Home Assistant configuration directory. +2. Restart Home Assistant. + +**Data:** the `nestquest.db` file (plus any `-wal`/`-shm` sidecars) is intentionally left untouched by both upgrade and rollback — back it up before upgrading. + +**Repository operators:** instead of uninstalling, git-revert the release merge on `main`. + ## Version 0.2.0 — 2026-09-14 ### Scope diff --git a/custom_components/nestquest/manifest.json b/custom_components/nestquest/manifest.json index 53cec3b..ffef4b2 100644 --- a/custom_components/nestquest/manifest.json +++ b/custom_components/nestquest/manifest.json @@ -10,5 +10,5 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/talon2king/NestQuest/issues", "requirements": [], - "version": "0.2.0" + "version": "0.3.0" } diff --git a/pyproject.toml b/pyproject.toml index 380e31e..cd7c2c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nestquest" -version = "0.2.0" +version = "0.3.0" description = "NestQuest Home Assistant Integration" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 0a9df36..13bd8ec 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "nestquest" -version = "0.2.0" +version = "0.3.0" source = { virtual = "." } [package.dev-dependencies] -- 2.55.0