Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d2b1af9d | ||
|
|
b67dda8655 | ||
|
|
e5b9146bab | ||
|
|
fa502730ef | ||
|
|
fe8ceb2f40 | ||
|
|
d1e4603a15 | ||
|
|
86bd4023f4 | ||
|
|
d34c08b5b7 | ||
|
|
020b3679e1 | ||
|
|
2265b5c3f1 | ||
|
|
c41e28cb29 |
@@ -4,7 +4,7 @@ Home Assistant custom integration for managing chores, custody schedules, and ho
|
||||
|
||||
## Overview
|
||||
|
||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.2.0 ships the integration foundation (a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow for horizon days / day rollover time / panel idle timeout, and HACS metadata) plus its persistence layer: an integration-owned SQLite database (`nestquest.db`) with WAL mode and foreign keys, a versioned migration runner stamping schema version 1 across eight tables (children, admin_users, schedule_rules, task_definitions, presence_schedules, presence_overrides, task_instances, completion_events), typed DAO layers so no other feature writes raw SQL, an append-only completion-event log, and startup handling that creates a missing database, opens a valid one, and refuses — without touching a single byte — to start on a corrupt one. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration.
|
||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. It provides sensors, calendar events, and service calls designed for busy households and modern co-parenting setups.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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
|
||||
|
||||
Second release of NestQuest, shipping Feature 02 — the persistence layer the integration owns outright:
|
||||
|
||||
- Integration-owned SQLite database (`nestquest.db`) in the Home Assistant config directory: WAL journal mode, foreign-key enforcement, executor-backed access so the event loop never blocks.
|
||||
- Schema v1 for all eight tables: `children`, `admin_users`, `schedule_rules`, `task_definitions`, `presence_schedules`, `presence_overrides`, `task_instances`, `completion_events` — validated by declarative CHECK constraints (no triggers).
|
||||
- Versioned migration runner: on first start the database is created at schema version 1; later starts are a verified no-op; a failed migration rolls back to its prior version. A database stamped with a future version refuses to start.
|
||||
- Typed DAO layers for every table so no other feature writes raw SQL. Assignment validation is atomic; `completion_events` is strictly append-only — the DAO exposes no update or delete method, and a package-wide guard keeps mutation SQL out of every module.
|
||||
- Startup handling for missing (created fresh), valid (opened), and corrupt (logged, retried, left byte-identical for backup restore) database files via a read-only `integrity_check` preflight that cannot write or create sidecars. An existing zero-byte file is treated as corrupt and left untouched, not silently initialized.
|
||||
|
||||
### 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 only).
|
||||
- No business logic yet: nothing materializes task instances and nothing can be completed — storage and schema only.
|
||||
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
|
||||
|
||||
### Rollback
|
||||
|
||||
Downgrading from 0.2.0 to 0.1.0 removes the data layer's code but leaves the `nestquest.db` file in place (0.1.0 never touches it, and no code path deletes an existing database). The file is compatible with this release's schema version stamp; a later reinstall of 0.2.0 or newer resumes from the stored version without re-migrating.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Downgrade via HACS (HACS > Integrations > NestQuest > Redownload > 0.1.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. Removing the integration via Home Assistant deletes the config entry but not the database file.
|
||||
|
||||
**Repository operators:** instead of uninstalling, git-revert the release merge on `main`. The database file is independent of the code and is never deleted by code.
|
||||
|
||||
## Version 0.1.0 — 2026-09-13
|
||||
|
||||
### Scope
|
||||
|
||||
First release of NestQuest, a Home Assistant custom integration for managing chores, custody schedules, and house coordination. This release ships the integration foundation/configuration scaffold only (Feature 01):
|
||||
|
||||
- Integration package and manifest, plus HACS metadata (`hacs.json`).
|
||||
- Shared constants module.
|
||||
- Single-instance config flow for initial setup.
|
||||
- Setup/unload lifecycle handling with an options-reload listener.
|
||||
- Options flow for configuring `horizon_days`, `day_rollover_time`, and `panel_idle_timeout`.
|
||||
- SQLite storage path resolver — path resolution only; the database itself is not created in this release.
|
||||
- Test harness (`pytest` via `uv`).
|
||||
|
||||
### 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.
|
||||
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
|
||||
- The integration does not yet create its database file; storage is limited to resolved configuration paths.
|
||||
|
||||
### Rollback
|
||||
|
||||
v0.1.0 is the first release — there is no previous NestQuest version to downgrade to, so rollback means complete removal. This release performs no data or schema migrations.
|
||||
|
||||
**HACS installs:**
|
||||
|
||||
1. Remove the NestQuest integration via HACS (HACS > Integrations > NestQuest > Remove).
|
||||
2. Restart Home Assistant.
|
||||
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
||||
|
||||
**Manual installs:**
|
||||
|
||||
1. Delete the `custom_components/nestquest/` directory from your Home Assistant configuration directory.
|
||||
2. Restart Home Assistant.
|
||||
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
||||
|
||||
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
|
||||
|
||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
||||
@@ -80,17 +80,8 @@ async def _preflight_existing_file(
|
||||
def _probe() -> None:
|
||||
# The file-state check and URI construction live inside the
|
||||
# executor job: exists/stat/resolve are disk I/O.
|
||||
if not db_path.exists():
|
||||
if not db_path.exists() or db_path.stat().st_size == 0:
|
||||
return
|
||||
# A zero-byte file is neither missing nor a valid database: it
|
||||
# is a corrupt (truncated-to-nothing) existing file, likely an
|
||||
# interrupted first write. The leave-untouched contract applies
|
||||
# to it too: refuse rather than silently initialize over it.
|
||||
if db_path.stat().st_size == 0:
|
||||
raise sqlite3.DatabaseError(
|
||||
"existing database file is empty (zero bytes): file is "
|
||||
"not a database"
|
||||
)
|
||||
# Properly escape the path: '#'/'?'/'%' in a directory or file
|
||||
# name would otherwise be parsed as URI fragments/parameters/
|
||||
# escapes. immutable=1: SQLite guarantees no writes and no
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
{
|
||||
"domain": "nestquest",
|
||||
"name": "NestQuest",
|
||||
"codeowners": [
|
||||
"@overseer"
|
||||
],
|
||||
"codeowners": ["@overseer"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/talon2king/NestQuest",
|
||||
"documentation": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
|
||||
"issue_tracker": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.3.0"
|
||||
"version": "0.1.0"
|
||||
}
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
"""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 (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
|
||||
|
||||
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")
|
||||
elif shape is RuleType.YEARLY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"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}"
|
||||
)
|
||||
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).
|
||||
|
||||
``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"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 _month_end(year: int, month: int) -> int:
|
||||
"""The last calendar day of the given month (leap-aware)."""
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""MONTHLY_DAY evaluation with the documented month-end policy.
|
||||
|
||||
Policy: in each interval-eligible month, a rule for a day-of-month
|
||||
that does not exist in that month (the 31st in April, the 30th in
|
||||
February, the 29th of February in a non-leap year) fires on the
|
||||
LAST day of that month instead of being skipped — the chore still
|
||||
happens that month, pinned to the month's end when its nominal date
|
||||
is absent.
|
||||
|
||||
The interval anchors on months ELAPSED from start_date's month: the
|
||||
rule fires when the months between the anchor month and the target's
|
||||
month form a multiple of the interval, keeping "every 2 months on
|
||||
the 15th" aligned to the anchor month even when the firing lands on
|
||||
a clamped month-end day.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def _nth_weekday_of_month(
|
||||
year: int, month: int, weekday: int, nth: int
|
||||
) -> int | None:
|
||||
"""The day-of-month of the ``nth`` ``weekday``, or None when the
|
||||
month has no such occurrence (``nth`` 5 beyond four occurrences).
|
||||
|
||||
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
|
||||
end (last occurrence, whatever the total). Pure calendar math.
|
||||
"""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
first_offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _occurs_monthly_weekday(
|
||||
rule: ScheduleRule, target: datetime.date
|
||||
) -> bool:
|
||||
"""MONTHLY_WEEKDAY evaluation.
|
||||
|
||||
Fires when the target is the ``nth`` occurrence of
|
||||
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
|
||||
month with fewer occurrences than ``nth`` (5 on a four-occurrence
|
||||
month) does not fire — n 5 is accepted by the model but skips such
|
||||
months, matching the done-condition. The interval anchors on
|
||||
nominal months elapsed from start_date's month.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.weekday() != rule.nth_weekday_weekday:
|
||||
return False
|
||||
nth_day = _nth_weekday_of_month(
|
||||
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
|
||||
)
|
||||
return nth_day is not None and target.day == nth_day
|
||||
|
||||
|
||||
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""YEARLY evaluation with the documented leap-day clamp policy.
|
||||
|
||||
Policy: in each interval-eligible year, the rule fires on
|
||||
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
|
||||
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
|
||||
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
|
||||
still happens every year, pinned to February's end when its
|
||||
nominal date is absent) rather than silently vanishing. A YEARLY
|
||||
rule with NO day_of_month fires on the first day of ``month``.
|
||||
The interval anchors on years elapsed from start_date's year.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
years_elapsed = target.year - anchor.year
|
||||
if years_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.month != rule.month:
|
||||
return False
|
||||
if rule.day_of_month is None:
|
||||
# Day-less yearly rules fire on the first of the month.
|
||||
return target.day == 1
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""Return True when ``rule`` fires on ``target``.
|
||||
|
||||
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 not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
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
|
||||
if shape is RuleType.DAILY:
|
||||
offset = (target - _parse_date(rule.start_date)).days
|
||||
return offset % rule.interval == 0
|
||||
if shape is RuleType.MONTHLY_DAY:
|
||||
return _occurs_monthly_day(rule, target)
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
return _occurs_monthly_weekday(rule, target)
|
||||
if shape is RuleType.YEARLY:
|
||||
return _occurs_yearly(rule, target)
|
||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||
# parity on January 1st; anchor arithmetic does not).
|
||||
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(
|
||||
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 not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
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
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.3.0"
|
||||
version = "0.1.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -56,24 +56,6 @@ def test_manifest_requirements(manifest: dict) -> None:
|
||||
assert manifest.get("requirements") == []
|
||||
|
||||
|
||||
def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None:
|
||||
"""Verify documentation URL points at the public GitHub mirror."""
|
||||
assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest"
|
||||
|
||||
|
||||
def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None:
|
||||
"""Verify issue_tracker URL points at the public GitHub mirror."""
|
||||
assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["documentation", "issue_tracker"])
|
||||
def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None:
|
||||
"""Verify manifest URLs use github.com rather than the private Gitea host."""
|
||||
url = manifest.get(key, "")
|
||||
assert url.startswith("https://github.com/talon2king/NestQuest")
|
||||
assert "cubecraftlabs" not in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
["name", "version", "documentation", "issue_tracker", "codeowners"],
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
"""Window-bound enforcement across every rule type (start_date/end_date).
|
||||
|
||||
Fixtures: each rule is anchored so its FIRST nominal firing IS
|
||||
start_date (the anchor day for daily/weekly/custom/monthly-weekday
|
||||
first-Tuesday/MONTHLY_DAY 31/MAR-YEARLY 31), which makes the pre-start
|
||||
probe meaningful: the day a week before start_date is a nominal
|
||||
matching day for the shape but must stay silent (out of window).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
def _tuesday(year: int, month: int, nth: int) -> datetime.date:
|
||||
"""The nth Tuesday of the month (nth >= 1)."""
|
||||
day = datetime.date(year, month, 1)
|
||||
while day.weekday() != 1:
|
||||
day += datetime.timedelta(days=1)
|
||||
return day + datetime.timedelta(weeks=nth - 1)
|
||||
|
||||
|
||||
def _friday(year: int, month: int, nth: int) -> datetime.date:
|
||||
day = datetime.date(year, month, 1)
|
||||
while day.weekday() != 4:
|
||||
day += datetime.timedelta(days=1)
|
||||
return day + datetime.timedelta(weeks=nth - 1)
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> datetime.date:
|
||||
if month == 12:
|
||||
return datetime.date(year, 12, 31)
|
||||
return datetime.date(year, month + 1, 1) - datetime.timedelta(days=1)
|
||||
|
||||
|
||||
#: One representative rule per type. Each fixture carries:
|
||||
#: kwargs, its start_date, the first nominal firing date, and the
|
||||
#: SECOND nominal firing date (all independently verified).
|
||||
#: - DAILY: start Mar 1; fires daily; second firing Mar 2.
|
||||
#: - WEEKLY (Tuesdays): start Tue Mar 3; second firing Tue Mar 10.
|
||||
#: - CUSTOM_DAYS (Wed + Fri): start Wed Mar 4; second firing Fri Mar 6.
|
||||
#: - MONTHLY_DAY (31st): start Tue Mar 31; second firing Apr 30 (clamp).
|
||||
#: - MONTHLY_WEEKDAY (2nd Tue): anchor Tue Mar 3 (first Tue); second
|
||||
#: nominal firing is the SECOND Tuesday, Mar 10.
|
||||
#: - YEARLY (Mar 31): start Tue Mar 31; second firing Mar 31 2027.
|
||||
BOUNDARY_RULES = [
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.DAILY,
|
||||
"start_date": "2026-03-01"},
|
||||
"first_firing": _d("2026-03-01"),
|
||||
"second_firing": _d("2026-03-02"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.WEEKLY, "weekday_set": {1},
|
||||
"start_date": "2026-03-03"},
|
||||
"first_firing": _d("2026-03-03"),
|
||||
"second_firing": _d("2026-03-10"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.CUSTOM_DAYS,
|
||||
"weekday_set": {2, 4}, "start_date": "2026-03-04"},
|
||||
"first_firing": _d("2026-03-04"),
|
||||
"second_firing": _d("2026-03-06"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.MONTHLY_DAY,
|
||||
"day_of_month": 31, "start_date": "2026-03-31"},
|
||||
"first_firing": _d("2026-03-31"),
|
||||
"second_firing": _d("2026-04-30"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.MONTHLY_WEEKDAY,
|
||||
"nth_weekday": 2, "nth_weekday_weekday": 1,
|
||||
"start_date": "2026-03-03"},
|
||||
# The rule fires on SECOND Tuesdays: Mar 3 is the FIRST Tue
|
||||
# (in-window but silent); the first nominal FIRING is Mar 10.
|
||||
"first_firing": _d("2026-03-10"),
|
||||
"second_firing": _friday(2026, 3, 2) if False else _d("2026-04-14"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.YEARLY, "month": 3,
|
||||
"day_of_month": 31, "start_date": "2026-03-31"},
|
||||
"first_firing": _d("2026-03-31"),
|
||||
"second_firing": _d("2027-03-31"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_date bound: nothing fires before it, for EVERY rule type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
|
||||
def test_no_firing_before_start_date(index) -> None:
|
||||
fixture = BOUNDARY_RULES[index]
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
before_start = _d(rule.start_date)
|
||||
# The sweep must cover the shape's PRIOR nominal occurrence:
|
||||
# - MONTHLY_DAY: the prior month's clamped day (e.g. Feb 28/31)
|
||||
# - YEARLY: the prior year's nominal day
|
||||
# - WEEKLY/CUSTOM_DAYS: the prior week's matching weekday
|
||||
# - MONTHLY_WEEKDAY: the prior month's nth weekday
|
||||
if rule.rule_type is RuleType.MONTHLY_DAY:
|
||||
sweep_from = _month_end(2026, 2) # Feb 28, a nominal clamp day
|
||||
elif rule.rule_type is RuleType.YEARLY:
|
||||
sweep_from = datetime.date(
|
||||
_d(rule.start_date).year - 1, rule.month, rule.day_of_month
|
||||
)
|
||||
elif rule.rule_type is RuleType.MONTHLY_WEEKDAY:
|
||||
sweep_from = _tuesday(2026, 2, 2) # prior month's 2nd Tuesday
|
||||
elif rule.rule_type is RuleType.WEEKLY:
|
||||
sweep_from = before_start - datetime.timedelta(days=7)
|
||||
elif rule.rule_type is RuleType.CUSTOM_DAYS:
|
||||
sweep_from = before_start - datetime.timedelta(days=7)
|
||||
else:
|
||||
sweep_from = before_start - datetime.timedelta(days=30)
|
||||
cursor = sweep_from
|
||||
while cursor < before_start:
|
||||
assert occurs_on(rule, cursor) is False, cursor
|
||||
cursor += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
|
||||
def test_first_nominal_firing_is_on_or_after_start(index) -> None:
|
||||
"""The first nominal firing must actually fire: the window must not
|
||||
exclude a date at or after start_date."""
|
||||
fixture = BOUNDARY_RULES[index]
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
first = fixture["first_firing"]
|
||||
assert occurs_on(rule, first) is True, first
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end_date bound: nothing fires after it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_firing_after_end_date_all_types() -> None:
|
||||
window_end = _d("2026-09-30")
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-09-30", **fixture["kwargs"]
|
||||
)
|
||||
# Sweep every day in the week after window_end: none may fire,
|
||||
# regardless of whether that date would nominally match.
|
||||
for offset in range(1, 8):
|
||||
after = window_end + datetime.timedelta(days=offset)
|
||||
assert occurs_on(rule, after) is False, after
|
||||
|
||||
|
||||
def test_no_firing_after_end_date_at_nominal_shapes() -> None:
|
||||
"""A post-end date that WOULD nominally match each shape must be
|
||||
silent — otherwise the end bound is untested noise."""
|
||||
rule_monthly = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-03-31", end_date="2026-04-30",
|
||||
)
|
||||
# Apr 30 is INSIDE the window (fires: clamp); May 31 would nominally
|
||||
# match but is after end_date: silent.
|
||||
assert occurs_on(rule_monthly, _d("2026-04-30")) is True
|
||||
assert occurs_on(rule_monthly, _d("2026-05-31")) is False
|
||||
|
||||
rule_yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
|
||||
start_date="2026-03-31", end_date="2027-03-31",
|
||||
)
|
||||
# Mar 31 2027 is inside; Mar 31 2028 would match but is past end.
|
||||
assert occurs_on(rule_yearly, _d("2027-03-31")) is True
|
||||
assert occurs_on(rule_yearly, _d("2028-03-31")) is False
|
||||
|
||||
rule_weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1},
|
||||
start_date="2026-03-03", end_date="2026-10-05",
|
||||
)
|
||||
# Tue Oct 6 would nominally match but is after end_date (Oct 5 is a
|
||||
# Monday, not in the set).
|
||||
assert occurs_on(rule_weekly, _d("2026-09-29")) is True
|
||||
assert occurs_on(rule_weekly, _d("2026-10-06")) is False
|
||||
|
||||
rule_monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-03-03",
|
||||
end_date="2026-10-05",
|
||||
)
|
||||
# The next nominal second Tuesday (Oct 13) is past end_date: silent.
|
||||
assert occurs_on(rule_monthly_weekday, _d("2026-09-08")) is True
|
||||
assert occurs_on(rule_monthly_weekday, _d("2026-10-13")) is False
|
||||
|
||||
|
||||
def test_no_end_date_means_no_upper_bound() -> None:
|
||||
"""A rule without end_date keeps firing arbitrarily far out — for
|
||||
DAILY, WEEKLY, CUSTOM_DAYS, MONTHLY_DAY, MONTHLY_WEEKDAY, YEARLY."""
|
||||
daily = ScheduleRule(rule_type=RuleType.DAILY,
|
||||
start_date="2026-03-01")
|
||||
assert occurs_on(daily, _d("2036-03-01")) is True
|
||||
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1},
|
||||
start_date="2026-03-03",
|
||||
)
|
||||
# 2076-03-03 is 50 years to the day after the anchor and a Tuesday.
|
||||
assert occurs_on(weekly, _d("2076-03-03")) is True
|
||||
|
||||
custom = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={4},
|
||||
start_date="2026-03-06", # a Friday
|
||||
)
|
||||
assert occurs_on(custom, _d("2026-03-06")) is True
|
||||
# The first Friday of March 2046 is Mar 2; Mar 6 is a Tuesday.
|
||||
assert occurs_on(custom, _d("2046-03-01")) is False # not in set
|
||||
assert occurs_on(custom, _d("2046-03-02")) is True
|
||||
|
||||
monthly_day = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(monthly_day, datetime.date(2036, 3, 15)) is True
|
||||
|
||||
monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, start_date="2026-03-04",
|
||||
)
|
||||
# First Wednesday of March 2036.
|
||||
d = datetime.date(2036, 3, 1)
|
||||
while d.weekday() != 2:
|
||||
d += datetime.timedelta(days=1)
|
||||
assert occurs_on(monthly_weekday, d) is True
|
||||
|
||||
yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
|
||||
start_date="2026-03-31",
|
||||
)
|
||||
assert occurs_on(yearly, datetime.date(2036, 3, 31)) is True
|
||||
|
||||
|
||||
def test_end_date_equal_to_start_date_allows_one_day_window() -> None:
|
||||
"""end == start: exactly one day can fire, for every rule type."""
|
||||
daily = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-03-15",
|
||||
end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(daily, _d("2026-03-15")) is True
|
||||
assert occurs_on(daily, _d("2026-03-14")) is False
|
||||
assert occurs_on(daily, _d("2026-03-16")) is False
|
||||
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1}, # 2026-03-10 is a Tue
|
||||
start_date="2026-03-10", end_date="2026-03-10",
|
||||
)
|
||||
# The window is a SINGLE day (end == start, a Tuesday in-set):
|
||||
# it fires, and the following Tuesday is out of window.
|
||||
assert _d("2026-03-10").weekday() == 1
|
||||
assert occurs_on(weekly, _d("2026-03-10")) is True
|
||||
assert occurs_on(weekly, _d("2026-03-17")) is False
|
||||
|
||||
custom = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 4},
|
||||
start_date="2026-03-11", end_date="2026-03-11",
|
||||
)
|
||||
# Mar 11 2026 is a Wednesday: fires. Next Wed (18) is out of window.
|
||||
assert _d("2026-03-11").weekday() == 2
|
||||
assert occurs_on(custom, _d("2026-03-11")) is True
|
||||
assert occurs_on(custom, _d("2026-03-18")) is False
|
||||
|
||||
monthly_day = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15", end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(monthly_day, _d("2026-03-15")) is True
|
||||
|
||||
monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-03-10",
|
||||
end_date="2026-03-10",
|
||||
)
|
||||
# Mar 10 2026 is the second Tuesday: the one-day window admits it.
|
||||
assert _d("2026-03-10").weekday() == 1
|
||||
assert occurs_on(monthly_weekday, _d("2026-03-10")) is True
|
||||
assert occurs_on(monthly_weekday, _d("2026-04-14")) is False
|
||||
|
||||
yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=15,
|
||||
start_date="2026-03-15", end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(yearly, _d("2026-03-15")) is True
|
||||
assert occurs_on(yearly, _d("2027-03-15")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end_date before start_date is rejected at construction, every type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"rule_type": RuleType.DAILY, "start_date": "2026-09-01",
|
||||
"end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.WEEKLY, "weekday_set": {0},
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.CUSTOM_DAYS, "weekday_set": {0},
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.MONTHLY_DAY, "day_of_month": 15,
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.MONTHLY_WEEKDAY, "nth_weekday": 1,
|
||||
"nth_weekday_weekday": 0, "start_date": "2026-09-01",
|
||||
"end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.YEARLY, "month": 6, "day_of_month": 15,
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
],
|
||||
)
|
||||
def test_end_before_start_rejected_for_every_type(kwargs) -> None:
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
ScheduleRule(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between respects the window for every type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_occurrences_between_respects_end_bound_all_types() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-06-30", **fixture["kwargs"]
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-12-31")
|
||||
# Every result inside the queried range, none beyond June.
|
||||
assert all(date <= "2026-06-30" for date in result), result
|
||||
assert all(
|
||||
date >= fixture["kwargs"]["start_date"] for date in result
|
||||
), result
|
||||
|
||||
|
||||
def test_occurrences_between_empty_when_range_before_start() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
assert occurrences_between(rule, "2026-01-01", "2026-02-28") == []
|
||||
|
||||
|
||||
def test_occurrences_between_empty_when_range_after_end() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-06-30", **fixture["kwargs"],
|
||||
)
|
||||
result = occurrences_between(rule, "2026-10-01", "2026-12-31")
|
||||
assert result == []
|
||||
@@ -1,444 +0,0 @@
|
||||
"""Consolidated recurrence suite over a fixed three-year calendar.
|
||||
|
||||
Every rule type runs across one fixed three-year window — 2026-01-01
|
||||
through 2028-12-31 — which spans TWO January 1st boundaries (2026->2027,
|
||||
2027->2028) and one leap year (2028, with a real February 29th). The
|
||||
expected date lists are hard-coded, independently computed with plain
|
||||
``datetime`` arithmetic (never the engine), and asserted exactly.
|
||||
|
||||
A final guard asserts the engine module imports nothing from
|
||||
``homeassistant`` and nothing from the database layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
ScheduleRule,
|
||||
RuleType,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
#: The fixed consolidated window: three full years.
|
||||
WINDOW_START = "2026-01-01"
|
||||
WINDOW_END = "2028-12-31"
|
||||
|
||||
|
||||
def _expected_from_pattern(
|
||||
anchor: str, weekdays: set[int] | None, interval: int,
|
||||
day_of_month: int | None, nth_weekday: tuple[int, int] | None,
|
||||
yearly: tuple[int, int | None] | None,
|
||||
) -> list[str]:
|
||||
"""Compute expected firings by plain datetime iteration (NOT the
|
||||
engine): the independent oracle for this suite."""
|
||||
start = _d(anchor)
|
||||
end = _d(WINDOW_END)
|
||||
cursor = start
|
||||
hits: list[str] = []
|
||||
while cursor <= end:
|
||||
if weekdays is not None:
|
||||
week_ok = ((cursor - start).days // 7) % interval == 0
|
||||
if week_ok and cursor.weekday() in weekdays:
|
||||
hits.append(cursor.isoformat())
|
||||
elif day_of_month is not None:
|
||||
months_elapsed = (
|
||||
(cursor.year - start.year) * 12
|
||||
+ (cursor.month - start.month)
|
||||
)
|
||||
if months_elapsed % interval == 0:
|
||||
month_end = (
|
||||
datetime.date(cursor.year, cursor.month, 1)
|
||||
if False
|
||||
else _month_end(cursor.year, cursor.month)
|
||||
)
|
||||
if cursor.day == min(day_of_month, month_end):
|
||||
hits.append(cursor.isoformat())
|
||||
elif nth_weekday is not None:
|
||||
nth, weekday = nth_weekday
|
||||
months_elapsed = (
|
||||
(cursor.year - start.year) * 12
|
||||
+ (cursor.month - start.month)
|
||||
)
|
||||
if months_elapsed % interval == 0 and cursor.weekday() == weekday:
|
||||
day = _nth_day(cursor.year, cursor.month, weekday, nth)
|
||||
if day is not None and cursor.day == day:
|
||||
hits.append(cursor.isoformat())
|
||||
elif yearly is not None:
|
||||
month, dom = yearly
|
||||
years_elapsed = cursor.year - start.year
|
||||
if years_elapsed % interval == 0 and cursor.month == month:
|
||||
if dom is None:
|
||||
if cursor.day == 1:
|
||||
hits.append(cursor.isoformat())
|
||||
else:
|
||||
month_end = _month_end(cursor.year, cursor.month)
|
||||
if cursor.day == min(dom, month_end):
|
||||
hits.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return hits
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> int:
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset_calc(first, weekday) + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _first_offset_calc(first, weekday):
|
||||
return (weekday - first.weekday()) % 7
|
||||
|
||||
|
||||
def _nth_day(year: int, month: int, weekday: int, nth: int) -> int | None:
|
||||
first = datetime.date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + offset + (max(nth, 0) - 1 if nth > 0 else 0) * 7
|
||||
if nth == -1:
|
||||
return _nth_day_back(year, month, weekday)
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _first_offset_calc(first, weekday):
|
||||
return (weekday - first.weekday()) % 7
|
||||
|
||||
|
||||
def _nth_day(year, month, weekday, nth): # noqa: F811
|
||||
"""Forward count for 1..5; back-count for -1."""
|
||||
if nth == -1:
|
||||
return _nth_day(year, month, weekday, -1) # pragma: no cover
|
||||
first = datetime.date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _nth_day(year, month, weekday, nth): # noqa: F811
|
||||
if nth != -1:
|
||||
return _forward_nth(year, month, weekday, nth)
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
|
||||
|
||||
def _forward_nth(year, month, weekday, nth):
|
||||
first = datetime.date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
# Consolidated rules: one per shape, anchored inside the window.
|
||||
CONSOLIDATED_RULES = [
|
||||
("daily", ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-01-01",
|
||||
)),
|
||||
("daily-x7", ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-05",
|
||||
)),
|
||||
("weekly-mwf", ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-01-05", # a Monday
|
||||
)),
|
||||
("custom-tts", ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
|
||||
start_date="2026-01-03", # a Saturday
|
||||
)),
|
||||
("monthly-31st", ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)),
|
||||
("monthly-2nd-wed", ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2, start_date="2026-01-01",
|
||||
)),
|
||||
("yearly-leap", ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)),
|
||||
("yearly-jun-15-x2", ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
|
||||
start_date="2026-06-15",
|
||||
)),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exact expected lists over the full three-year window
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_daily_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY,
|
||||
start_date="2026-01-01")
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# Independent oracle: plain datetime iteration over the whole window.
|
||||
expected = []
|
||||
cursor = _d("2026-01-01")
|
||||
end = _d(WINDOW_END)
|
||||
while cursor <= end:
|
||||
expected.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
assert result == expected
|
||||
# 365 + 365 + 366 = 1096 days: every single day fires.
|
||||
assert len(result) == 1096
|
||||
assert result[0] == "2026-01-01"
|
||||
assert result[-1] == "2028-12-31"
|
||||
assert "2028-02-29" in result
|
||||
assert len(result) == len(set(result))
|
||||
|
||||
|
||||
def test_daily_x7_exact_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=7, start_date="2026-01-01"
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# Offsets 0, 7, 14, ... <= 1095: 157 firings.
|
||||
assert len(result) == 157
|
||||
assert result[0] == "2026-01-01"
|
||||
# Every firing is exactly 7 days after the previous one.
|
||||
dates = [_d(date) for date in result]
|
||||
gaps = {
|
||||
(second - first).days
|
||||
for first, second in zip(dates, dates[1:])
|
||||
}
|
||||
assert gaps == {7}
|
||||
# Boundary firings: the offsets divisible by 7 nearest each event.
|
||||
# Jan 1 2027 = offset 365 (skipped, 365 % 7 = 1); nearest firing:
|
||||
anchor = datetime.date(2026, 1, 1)
|
||||
for probe in ("2026-12-31", "2027-01-01", "2027-12-31", "2028-01-01",
|
||||
"2028-02-25", "2028-02-29"):
|
||||
offset = (datetime.date.fromisoformat(probe)
|
||||
- datetime.date(2026, 1, 1)).days
|
||||
expected_fire = offset % 7 == 0
|
||||
fired = probe in result
|
||||
assert fired is expected_fire, (
|
||||
f"{probe}: offset {offset}, fired {fired}, "
|
||||
f"expected {expected_fire}"
|
||||
)
|
||||
|
||||
|
||||
def test_weekly_mwf_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-01-05", # a Monday
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# Independently count Mon/Wed/Fri in 2026-2028.
|
||||
expected = []
|
||||
cursor = _d("2026-01-05")
|
||||
end = _d(WINDOW_END)
|
||||
while cursor <= end:
|
||||
if cursor.weekday() in (0, 2, 4):
|
||||
expected.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
assert result == expected
|
||||
# Three years of M/W/F: 156 weeks * 3 + 2 (2028 has a leap day but
|
||||
# weekdays repeat weekly) -> verify the count matches plain math.
|
||||
assert len(expected) == 468
|
||||
|
||||
|
||||
def test_custom_tues_sats_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 6},
|
||||
start_date="2026-01-03", # a Saturday
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
expected = []
|
||||
cursor = _d("2026-01-03")
|
||||
end = _d(WINDOW_END)
|
||||
while cursor <= end:
|
||||
if cursor.weekday() in (2, 6):
|
||||
expected.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_monthly_31st_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# Independent oracle: for each month, min(31, month_end).
|
||||
expected = []
|
||||
for year in (2026, 2027, 2028):
|
||||
for month in range(1, 13):
|
||||
month_end = _month_end(year, month)
|
||||
expected.append(
|
||||
f"{year:04d}-{month:02d}-{min(31, month_end):02d}"
|
||||
)
|
||||
assert result == expected
|
||||
assert len(result) == 36
|
||||
assert "2026-02-28" in result
|
||||
assert "2027-02-28" in result
|
||||
assert "2028-02-29" in result
|
||||
assert "2026-04-30" in result
|
||||
assert "2028-04-30" in result
|
||||
|
||||
|
||||
def test_monthly_2nd_wed_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2, start_date="2026-01-01",
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# 36 months, one firing each: the second Wednesday.
|
||||
assert len(result) == 36
|
||||
expected = []
|
||||
for year in (2026, 2027, 2028):
|
||||
for month in range(1, 13):
|
||||
d = datetime.date(year, month, 1)
|
||||
weds = []
|
||||
while d.month == month and d.year == year:
|
||||
if d.weekday() == 2:
|
||||
weds.append(d.day)
|
||||
d += datetime.timedelta(days=1)
|
||||
expected.append(f"{year:04d}-{month:02d}-{weds[1]:02d}")
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_yearly_leap_exact_three_year_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# 2026 clamp Feb 28, 2027 clamp Feb 28, 2028 leap Feb 29.
|
||||
assert result == ["2026-02-28", "2027-02-28", "2028-02-29"]
|
||||
|
||||
|
||||
def test_yearly_jun_15_interval_2_exact_list() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
result = occurrences_between(rule, WINDOW_START, WINDOW_END)
|
||||
# y0 2026, y2 2028 (y3 2029 excluded by the 3-year window).
|
||||
assert result == ["2026-06-15", "2028-06-15"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-Jan 1 and leap-day probes for every consolidated rule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_rule_evaluates_cleanly_around_both_jan_1s() -> None:
|
||||
"""Both Jan 1 boundaries: a 3-day window either side must agree with
|
||||
per-date occurs_on, and the engine must not crash on any day."""
|
||||
for name, rule in (
|
||||
("daily", CONSOLIDATED_RULES[0][1]),
|
||||
("daily-x7", CONSOLIDATED_RULES[1][1]),
|
||||
("weekly-mwf", CONSOLIDATED_RULES[2][1]),
|
||||
("custom-tts", CONSOLIDATED_RULES[3][1]),
|
||||
("monthly-31st", CONSOLIDATED_RULES[4][1]),
|
||||
("monthly-2nd-wed", CONSOLIDATED_RULES[5][1]),
|
||||
("yearly-leap", CONSOLIDATED_RULES[6][1]),
|
||||
("yearly-jun-15-x2", CONSOLIDATED_RULES[7][1]),
|
||||
):
|
||||
for jan_1 in ("2027-01-01", "2028-01-01"):
|
||||
boundary = _d(jan_1)
|
||||
window = occurrences_between(
|
||||
rule,
|
||||
(boundary - datetime.timedelta(days=3)).isoformat(),
|
||||
(boundary + datetime.timedelta(days=3)).isoformat(),
|
||||
)
|
||||
# Ordered and duplicate-free.
|
||||
assert window == sorted(set(window))
|
||||
# Per-date agreement around the boundary.
|
||||
for offset in range(-3, 4):
|
||||
date = boundary + datetime.timedelta(days=offset)
|
||||
fired = occurs_on(rule, date)
|
||||
in_list = date.isoformat() in window
|
||||
assert fired == in_list, (
|
||||
f"{name} {date}: occurs_on={fired}, in_list={in_list}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Purity guard: the engine imports nothing from HA or the DB layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recurrence_engine_imports_nothing_forbidden() -> None:
|
||||
"""recurrence.py must not import homeassistant, sqlite, or any DB/
|
||||
store/schema/DAO module — verified via the resolved module file."""
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
module_file = Path(
|
||||
importlib.util.find_spec(
|
||||
"custom_components.nestquest.recurrence"
|
||||
).origin
|
||||
)
|
||||
tree = ast.parse(module_file.read_text())
|
||||
forbidden_leaf = {
|
||||
"db", "schema", "dao_children", "dao_rules", "dao_presence",
|
||||
"dao_instances", "migrations", "store",
|
||||
}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
lowered = alias.name.lower()
|
||||
leaf = lowered.split(".")[-1]
|
||||
assert "homeassistant" not in lowered
|
||||
assert "sqlite" not in lowered
|
||||
assert leaf not in forbidden_leaf
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = (node.module or "").lower()
|
||||
assert "homeassistant" not in module
|
||||
assert "sqlite" not in module
|
||||
# The MODULE's leaf name carries the forbidden name for
|
||||
# 'from .store import Store' style imports.
|
||||
module_leaf = module.split(".")[-1] if module else ""
|
||||
assert module_leaf not in forbidden_leaf, module_leaf
|
||||
for alias in node.names:
|
||||
leaf = alias.name.split(".")[-1].lower()
|
||||
assert leaf not in forbidden_leaf
|
||||
|
||||
|
||||
from pathlib import Path as _Path # noqa: E402
|
||||
|
||||
|
||||
def Path_of(path: str) -> _Path: # noqa: N802
|
||||
return _Path(path)
|
||||
|
||||
|
||||
def _nth_day(year, month, weekday, nth): # noqa: F811
|
||||
"""Forward-count for 1..5; back-count for -1 (canonical helper)."""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
return last.day - ((last.weekday() - weekday) % 7)
|
||||
first = datetime.date(year, month, 1)
|
||||
offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
@@ -1,289 +0,0 @@
|
||||
"""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_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"}
|
||||
)
|
||||
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
|
||||
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"]
|
||||
|
||||
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
"""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, 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:
|
||||
"""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)
|
||||
@@ -1,301 +0,0 @@
|
||||
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_month_end,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nominal-day firings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_day_15_fires_on_the_15th() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
for month in range(1, 13):
|
||||
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_on_other_days() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
|
||||
start_date="2026-01-10",
|
||||
)
|
||||
# Every non-10th day of a short month must be False.
|
||||
for day in range(1, 30):
|
||||
if day != 10:
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, 1, day)
|
||||
) is False, f"Jan {day}"
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15",
|
||||
)
|
||||
# Feb 15 precedes the start: no firing even though the day matches.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15", end_date="2026-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Month-end clamp policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_month_end_helper_is_leap_aware() -> None:
|
||||
assert _month_end(2026, 1) == 31
|
||||
assert _month_end(2026, 4) == 30
|
||||
assert _month_end(2026, 2) == 28 # non-leap
|
||||
assert _month_end(2028, 2) == 29 # leap
|
||||
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
|
||||
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
|
||||
assert _month_end(2026, 12) == 31
|
||||
|
||||
|
||||
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Months with a 31st: fire on the 31st.
|
||||
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
|
||||
(10, 31), (12, 31)):
|
||||
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
|
||||
f"2026-{month:02d}-{day:02d}"
|
||||
)
|
||||
# Months WITHOUT a 31st: fire on the month's last day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
|
||||
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
|
||||
# The day BEFORE the clamp is not a firing.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_clamps_to_feb_28_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2026-01-30",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
|
||||
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
|
||||
# Months with a 30th: nominal day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
|
||||
|
||||
|
||||
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2026-01-29",
|
||||
)
|
||||
# Non-leap 2027: Feb 28 is the clamp.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
|
||||
# Leap 2028: Feb 29 exists — nominal day.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_feb_leap_year_clamps_to_29() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2027-01-30",
|
||||
)
|
||||
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
|
||||
"""Every (month, day) in ``rule``'s year the engine says fires."""
|
||||
hits = []
|
||||
for month in range(1, 13):
|
||||
for day in range(1, _month_end(year, month) + 1):
|
||||
if occurs_on(rule, datetime.date(year, month, day)):
|
||||
hits.append((month, day))
|
||||
return hits
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2026() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Exactly one firing per month, twelve total.
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
assert len(hits) == 12
|
||||
by_month = dict(hits)
|
||||
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
|
||||
assert by_month[2] == 28 # 2026 Feb is non-leap
|
||||
assert by_month[4] == 30
|
||||
# Each month has exactly one firing.
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2028-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap February clamps to the 29th
|
||||
assert by_month[4] == 30
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_30_sweep_2028() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2028-01-30",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb clamps to 29
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2027_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2027-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2027)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2028-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_interval_2_every_second_month() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
|
||||
"""Interval counts nominal months from the anchor: Jan 31 anchor,
|
||||
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
|
||||
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
|
||||
|
||||
|
||||
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-11-15",
|
||||
)
|
||||
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
|
||||
assert result == [
|
||||
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
|
||||
"2026-05-31", "2026-06-30",
|
||||
]
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-06-15", "2026-01-15")
|
||||
|
||||
|
||||
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
|
||||
"""The clamp must not double-fire: only one day per month matches."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
months = [month for month, _ in hits]
|
||||
assert months == sorted(months)
|
||||
assert len(months) == len(set(months)) == 12
|
||||
@@ -1,324 +0,0 @@
|
||||
"""Tests for MONTHLY_WEEKDAY (nth-weekday-of-month) recurrence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_nth_weekday_of_month,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The helper: nth occurrence day-of-month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nth_weekday_helper_first_second_third() -> None:
|
||||
"""Independently verified: Mar 1 2026 is a Sunday; Wednesdays are
|
||||
4, 11, 18, 25 (four — no 5th)."""
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 1) == 4
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 2) == 11
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 3) == 18
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 4) == 25
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
|
||||
|
||||
|
||||
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
|
||||
"""Independently verified: April 2026 Fridays are 3, 10, 17, 24
|
||||
(four) — no 5th occurrence."""
|
||||
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
|
||||
|
||||
|
||||
def test_nth_weekday_helper_last() -> None:
|
||||
"""Independently verified: March 2026 Fridays are 6, 13, 20, 27;
|
||||
February 2028 Saturdays are 5, 12, 19, 26 (Feb 29 2028 is a
|
||||
Tuesday, so the last SATURDAY is the 26th, not the last day)."""
|
||||
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
|
||||
# February 2026: 28 days, last day IS a Saturday.
|
||||
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
|
||||
# February 2028: the last Saturday is the 26th (Feb 29 is a Tue).
|
||||
assert _nth_weekday_of_month(2028, 2, 5, -1) == 26
|
||||
|
||||
|
||||
def test_nth_weekday_helper_last_when_month_has_five() -> None:
|
||||
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
|
||||
(five) — the last is the 30th, and the 5th is the same day."""
|
||||
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
|
||||
assert _nth_weekday_of_month(2026, 10, 4, 5) == 30
|
||||
|
||||
|
||||
def test_helper_zero_and_out_of_range_nth() -> None:
|
||||
"""The model rejects n 0 before the helper sees it; -1/1..5 only."""
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=0,
|
||||
nth_weekday_weekday=1, start_date="2026-09-01",
|
||||
)
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=6,
|
||||
nth_weekday_weekday=1, start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurs_on: first / second / third / fourth / last across 12 months
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_first_tuesday_of_twelve_consecutive_months() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, # first Wednesday
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
first_wednesdays = [
|
||||
(1, 7), (2, 4), (3, 4), (4, 1), (5, 6), (6, 3),
|
||||
(7, 1), (8, 5), (9, 2), (10, 7), (11, 4), (12, 2),
|
||||
]
|
||||
for month, day in first_wednesdays:
|
||||
date = datetime.date(2026, month, day)
|
||||
assert date.weekday() == 2
|
||||
assert occurs_on(rule, date) is True, date
|
||||
# Neighbors do not fire.
|
||||
assert occurs_on(rule, date - datetime.timedelta(days=7)) is False
|
||||
assert occurs_on(rule, date + datetime.timedelta(days=7)) is False
|
||||
|
||||
|
||||
def test_second_wednesday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified second Wednesdays of 2026 (Jan 1 is a
|
||||
Thursday; Wednesdays per month from the calendar): the SECOND one
|
||||
fires; the FIRST one does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
# (month, first_wed, second_wed) from plain datetime arithmetic:
|
||||
verified = [
|
||||
(1, 7, 14), (2, 4, 11), (3, 4, 11), (4, 1, 8),
|
||||
(5, 6, 13), (6, 3, 10), (7, 1, 8), (8, 5, 12),
|
||||
(9, 2, 9), (10, 7, 14), (11, 4, 11), (12, 2, 9),
|
||||
]
|
||||
for month, first_wed, second_wed in verified:
|
||||
assert _d(f"2026-{month:02d}-{first_wed:02d}").weekday() == 2
|
||||
assert _d(f"2026-{month:02d}-{second_wed:02d}").weekday() == 2
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, second_wed)
|
||||
) is True, f"2026-{month:02d}-{second_wed:02d}"
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, first_wed)
|
||||
) is False, f"2026-{month:02d}-{first_wed:02d}"
|
||||
|
||||
|
||||
def test_third_friday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified 2026 Fridays; the THIRD fires, the SECOND
|
||||
does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
|
||||
nth_weekday_weekday=4,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, first_fri, second_fri, third_fri)
|
||||
(1, 2, 9, 16), (2, 6, 13, 20), (3, 6, 13, 20),
|
||||
(4, 3, 10, 17), (5, 1, 8, 15), (6, 5, 12, 19),
|
||||
(7, 3, 10, 17), (8, 7, 14, 21), (9, 4, 11, 18),
|
||||
(10, 2, 9, 16), (11, 6, 13, 20), (12, 4, 11, 18),
|
||||
]
|
||||
for month, first_fri, second_fri, third_fri in verified:
|
||||
third = _d(f"2026-{month:02d}-{third_fri:02d}")
|
||||
assert third.weekday() == 4
|
||||
assert occurs_on(rule, third) is True, third
|
||||
second = _d(f"2026-{month:02d}-{second_fri:02d}")
|
||||
assert occurs_on(rule, second) is False, second
|
||||
|
||||
|
||||
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified 2026 Wednesdays; the FOURTH fires, the
|
||||
THIRD does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
|
||||
nth_weekday_weekday=2,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, third_wed, fourth_wed)
|
||||
(1, 21, 28), (2, 18, 25), (3, 18, 25), (4, 15, 22),
|
||||
(5, 20, 27), (6, 17, 24), (7, 15, 22), (8, 19, 26),
|
||||
(9, 16, 23), (10, 21, 28), (11, 18, 25), (12, 16, 23),
|
||||
]
|
||||
for month, third_wed, fourth_wed in verified:
|
||||
fourth = _d(f"2026-{month:02d}-{fourth_wed:02d}")
|
||||
assert fourth.weekday() == 2
|
||||
assert occurs_on(rule, fourth) is True, fourth
|
||||
third = _d(f"2026-{month:02d}-{third_wed:02d}")
|
||||
assert not occurs_on(rule, third), third
|
||||
|
||||
|
||||
def test_last_monday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified last Mondays of 2026; each fires, and the
|
||||
PREVIOUS Monday of the same month does not (it is nth-from-last
|
||||
minus one, i.e. the second-to-last)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||
nth_weekday_weekday=0,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, last_monday)
|
||||
(1, 26), (2, 23), (3, 30), (4, 27), (5, 25), (6, 29),
|
||||
(7, 27), (8, 31), (9, 28), (10, 26), (11, 30), (12, 28),
|
||||
]
|
||||
for month, last_monday in verified:
|
||||
date = _d(f"2026-{month:02d}-{last_monday:02d}")
|
||||
assert date.weekday() == 0
|
||||
assert occurs_on(rule, date) is True, date
|
||||
# The Monday a week earlier is NOT the last: must not fire.
|
||||
week_before = date - datetime.timedelta(days=7)
|
||||
if week_before.month == month:
|
||||
assert occurs_on(rule, week_before) is False, week_before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The five-occurrence skip: a 5th on a four-occurrence month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fifth_weekday_skips_four_occurrence_months() -> None:
|
||||
"""Independently verified: 2026 months with FIVE Mondays are March,
|
||||
June, August, November — every other month (four Mondays) must fire
|
||||
on no day at all, and each hit must be the month's FINAL Monday."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||
nth_weekday_weekday=0, # fifth Monday
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
five_monday_months = {3: 30, 6: 29, 8: 31, 11: 30}
|
||||
hit_months = set()
|
||||
for month in range(1, 13):
|
||||
if month in five_monday_months:
|
||||
day = five_monday_months[month]
|
||||
hit = _d(f"2026-{month:02d}-{day:02d}")
|
||||
assert hit.weekday() == 0
|
||||
assert occurs_on(rule, hit) is True, hit
|
||||
# It is the FINAL Monday of the month: sweep EVERY remaining
|
||||
# calendar day after the hit — none may fire.
|
||||
for later in range(day + 1, _month_end_days(2026, month) + 1):
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, later)
|
||||
) is False, f"2026-{month:02d}-{later:02d}"
|
||||
# The Monday a week earlier is not the fifth.
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, day - 7)
|
||||
) is False
|
||||
hit_months.add(month)
|
||||
else:
|
||||
for day in range(1, _month_end_days(2026, month) + 1):
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, day)
|
||||
) is False, f"2026-{month:02d}-{day:02d}"
|
||||
assert hit_months == {3, 6, 8, 11}, hit_months
|
||||
|
||||
|
||||
def _month_end_days(year: int, month: int) -> int:
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def test_fifth_weekday_fires_in_five_occurrence_months() -> None:
|
||||
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
|
||||
(five) — the 5th (and last) is the 30th; April 2026 has four Fridays
|
||||
(3, 10, 17, 24) so no day in April fires."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||
nth_weekday_weekday=4, # Fridays
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
# October: the 30th fires, is a Friday, and no Monday-later day
|
||||
# (the 31st) is a Friday: the 30th is the FINAL Friday.
|
||||
oct_30 = datetime.date(2026, 10, 30)
|
||||
assert oct_30.weekday() == 4
|
||||
assert occurs_on(rule, oct_30) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 10, 31)) is False
|
||||
# April: no Friday-5th, so no day fires.
|
||||
for day in range(1, _month_end_days(2026, 4) + 1):
|
||||
assert occurs_on(rule, datetime.date(2026, 4, day)) is False, (
|
||||
f"2026-04-{day:02d}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_weekday_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2, interval=2, start_date="2026-01-01",
|
||||
)
|
||||
# Jan (m0) fires on the 2nd Tuesday; Feb (m1) skipped; Mar (m2) fires.
|
||||
jan_second = _nth_weekday_of_month(2026, 1, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, jan_second)) is True
|
||||
feb_second = _nth_weekday_of_month(2026, 2, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, feb_second)) is False
|
||||
mar_second = _nth_weekday_of_month(2026, 3, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 3, mar_second)) is True
|
||||
|
||||
|
||||
def test_monthly_weekday_interval_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, interval=3, start_date="2026-11-01",
|
||||
)
|
||||
# Nov (m0) fires; Dec (m1), Jan (m2) skip; Feb (m3) fires.
|
||||
nov_first = _nth_weekday_of_month(2026, 11, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2026, 11, nov_first)) is True
|
||||
dec_first = _nth_weekday_of_month(2026, 12, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2026, 12, dec_first)) is False
|
||||
jan_first = _nth_weekday_of_month(2027, 1, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2027, 1, jan_first)) is False
|
||||
feb_first = _nth_weekday_of_month(2027, 2, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2027, 2, feb_first)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_weekday_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=4, # second Friday
|
||||
start_date="2026-01-01", end_date="2026-04-30",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-04-30")
|
||||
# Independently verified second Fridays: Jan 9 (Jan 1 Thu), Feb 13
|
||||
# (Feb 1 Sun), Mar 13 (Mar 1 Sun), Apr 10 (Apr 1 Wed).
|
||||
assert result == [
|
||||
"2026-01-09", "2026-02-13", "2026-03-13", "2026-04-10",
|
||||
]
|
||||
|
||||
|
||||
def test_monthly_weekday_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=1, start_date="2026-01-01",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-06-01", "2026-01-01")
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Consistency property tests for occurrences_between vs occurs_on.
|
||||
|
||||
The done-condition: occurrences_between returns an ordered, duplicate-
|
||||
free list of dates that agrees EXACTLY with calling occurs_on on every
|
||||
date in the range. This module proves that agreement two ways:
|
||||
|
||||
1. An exhaustive sweep over a fixed grid of rules (every rule type ×
|
||||
several intervals × several windows) cross-checked date by date.
|
||||
|
||||
2. A randomized property test (seeded, deterministic) over randomly
|
||||
generated rules and ranges asserting the two functions never
|
||||
disagree. Seeds are fixed so failures are reproducible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
def _check_consistency(
|
||||
rule: ScheduleRule, start: str, end: str
|
||||
) -> list[str]:
|
||||
"""Cross-check occurrences_between against per-date occurs_on.
|
||||
|
||||
Returns the occurrences_between result after asserting, for every
|
||||
date in [start, end]: occurs_on(date) is True iff the date appears
|
||||
in the result. Also asserts the list is ordered and duplicate-free.
|
||||
"""
|
||||
result = occurrences_between(rule, start, end)
|
||||
# Ordered and duplicate-free.
|
||||
assert result == sorted(set(result)), (
|
||||
f"occurrences_between must be ordered, duplicate-free: {result}"
|
||||
)
|
||||
# Exact agreement with occurs_on over every date in the range.
|
||||
cursor = _d(start)
|
||||
end_date = _d(end)
|
||||
expected: list[str] = []
|
||||
while cursor <= end_date:
|
||||
if occurs_on(rule, cursor):
|
||||
expected.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
assert result == expected, (
|
||||
f"disagreement for {rule.to_dict()} over [{start}, {end}]:\n"
|
||||
f" occurrences_between: {result}\n"
|
||||
f" per-date occurs_on: {expected}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exhaustive grid over rule shapes, intervals and windows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_GRID_RULES = [
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-03-01"),
|
||||
ScheduleRule(rule_type=RuleType.DAILY, interval=2,
|
||||
start_date="2026-03-01"),
|
||||
ScheduleRule(rule_type=RuleType.DAILY, interval=3,
|
||||
start_date="2026-03-15", end_date="2027-01-31"),
|
||||
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-03-03"),
|
||||
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
|
||||
interval=2, start_date="2026-03-06",
|
||||
end_date="2027-06-30"),
|
||||
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 4},
|
||||
start_date="2026-03-06"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-03-31"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
interval=2, start_date="2026-01-29"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-03-03"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||
nth_weekday_weekday=4, start_date="2026-03-06",
|
||||
end_date="2028-12-31"),
|
||||
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28"),
|
||||
ScheduleRule(rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
interval=2, start_date="2026-06-15"),
|
||||
]
|
||||
|
||||
_GRID_WINDOWS = [
|
||||
("2026-01-01", "2026-03-31"), # mostly before start
|
||||
("2026-03-01", "2026-03-01"), # single day == anchor
|
||||
("2026-03-01", "2026-03-31"), # within window
|
||||
("2026-08-01", "2026-08-31"), # mid-window month
|
||||
("2026-12-01", "2027-01-31"), # year boundary
|
||||
("2028-01-01", "2028-03-01"), # leap year span
|
||||
("2029-01-01", "2029-12-31"), # far out
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule_index,window_index",
|
||||
[
|
||||
(rule_index, window_index)
|
||||
for rule_index in range(len(_GRID_RULES))
|
||||
for window_index in range(len(_GRID_WINDOWS))
|
||||
],
|
||||
)
|
||||
def test_grid_occurrences_between_matches_occurs_on(
|
||||
rule_index, window_index
|
||||
) -> None:
|
||||
rule = _GRID_RULES[rule_index]
|
||||
window = _GRID_WINDOWS[window_index]
|
||||
_check_consistency(rule, window[0], window[1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Randomized property test (seeded, deterministic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generate_random_rules(rng: random.Random, count: int) -> list[ScheduleRule]:
|
||||
"""Random but valid rules: shapes, intervals, anchors, windows.
|
||||
|
||||
Any random combo the model rejects (genuinely invalid combinations)
|
||||
is skipped — the property only needs VALID rules; the assertion at
|
||||
the end bounds how many may be rejected.
|
||||
"""
|
||||
rules: list[ScheduleRule] = []
|
||||
shapes = list(RuleType)
|
||||
for index in range(count):
|
||||
shape = shapes[rng.randrange(len(shapes))]
|
||||
interval = rng.choice([1, 2, 3, 7])
|
||||
anchor = datetime.date(
|
||||
rng.choice([2024, 2025, 2026, 2027, 2028]),
|
||||
rng.randrange(1, 13),
|
||||
rng.randrange(1, 28),
|
||||
)
|
||||
end_offset = rng.choice([45, 120, 400, 800])
|
||||
end = anchor + datetime.timedelta(days=end_offset)
|
||||
common = {
|
||||
"rule_type": shape,
|
||||
"interval": interval,
|
||||
"start_date": anchor.isoformat(),
|
||||
"end_date": end.isoformat(),
|
||||
}
|
||||
try:
|
||||
if shape is RuleType.WEEKLY:
|
||||
rules.append(ScheduleRule(
|
||||
**common,
|
||||
weekday_set={
|
||||
rng.randrange(7)
|
||||
for _ in range(rng.randrange(1, 4))
|
||||
},
|
||||
))
|
||||
elif shape is RuleType.CUSTOM_DAYS:
|
||||
rules.append(ScheduleRule(
|
||||
**common,
|
||||
weekday_set={
|
||||
rng.randrange(7)
|
||||
for _ in range(rng.randrange(1, 4))
|
||||
},
|
||||
))
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
rules.append(ScheduleRule(
|
||||
**common, day_of_month=rng.randrange(1, 32)
|
||||
))
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
rules.append(ScheduleRule(
|
||||
**common,
|
||||
nth_weekday=rng.choice([-1, 1, 2, 3, 4, 5]),
|
||||
nth_weekday_weekday=rng.randrange(7),
|
||||
))
|
||||
elif shape is RuleType.YEARLY:
|
||||
rules.append(ScheduleRule(
|
||||
**common,
|
||||
month=rng.randrange(1, 13),
|
||||
day_of_month=rng.choice([None, rng.randrange(1, 29)]),
|
||||
))
|
||||
else: # DAILY
|
||||
rules.append(ScheduleRule(**common))
|
||||
except RuleValidationError:
|
||||
continue
|
||||
assert len(rules) >= count * 3 // 4, "too many random rules rejected"
|
||||
return rules
|
||||
|
||||
|
||||
def test_random_rules_never_disagree_between_the_two_functions() -> None:
|
||||
"""The property: for randomly generated rules and ranges,
|
||||
occurrences_between(rule, s, e) == [d for d in s..e if occurs_on]."""
|
||||
rng = random.Random(0xFEA7) # fixed seed for reproducibility
|
||||
rules = _generate_random_rules(rng, count=40)
|
||||
checked = 0
|
||||
for rule in rules:
|
||||
anchor = datetime.date.fromisoformat(rule.start_date)
|
||||
for offset_days, span_days in ((-30, 60), (0, 14), (10, 90),
|
||||
(100, 200)):
|
||||
range_start = anchor + datetime.timedelta(days=offset_days)
|
||||
range_end = range_start + datetime.timedelta(days=span_days)
|
||||
_check_consistency(
|
||||
rule, range_start.isoformat(), range_end.isoformat()
|
||||
)
|
||||
checked += 1
|
||||
assert checked >= 40 * 4 * 3 // 4, checked
|
||||
|
||||
|
||||
def test_random_ranges_include_year_and_leap_spans() -> None:
|
||||
"""Every rule is probed across a range that spans Jan 1 (twice) and
|
||||
a February 29 — the Dec 1..Mar 1 two-year span always covers both."""
|
||||
rng = random.Random(42)
|
||||
rules = _generate_random_rules(rng, count=12)
|
||||
for rule in rules:
|
||||
anchor = datetime.date.fromisoformat(rule.start_date)
|
||||
probe_start = datetime.date(anchor.year, 12, 1)
|
||||
probe_end = datetime.date(anchor.year + 2, 3, 1)
|
||||
# The span always contains two Jan 1s and a Feb 28/29 pair.
|
||||
assert probe_end >= probe_start
|
||||
_check_consistency(
|
||||
rule, probe_start.isoformat(), probe_end.isoformat()
|
||||
)
|
||||
# An additional probe pinned ON a Feb 29 window (2028 leap):
|
||||
# every rule is cross-checked over Feb 1 - Mar 15 2028.
|
||||
_check_consistency(rule, "2028-02-01", "2028-03-15")
|
||||
|
||||
|
||||
def test_occurrences_between_single_day_range_matches_occurs_on() -> None:
|
||||
"""A one-day range is the exact occurs_on of that date."""
|
||||
for rule in _GRID_RULES:
|
||||
for day in ("2026-03-05", "2026-03-15", "2027-06-15"):
|
||||
single = occurrences_between(rule, day, day)
|
||||
if occurs_on(rule, _d(day)):
|
||||
assert single == [day], (rule.to_dict(), day, single)
|
||||
else:
|
||||
assert single == [], (rule.to_dict(), day, single)
|
||||
@@ -1,299 +0,0 @@
|
||||
"""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
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Tests for YEARLY recurrence with the leap-day clamp policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nominal-day firings across four consecutive years (incl. leap 2028)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_june_15_four_consecutive_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
for year in (2026, 2027, 2028, 2029):
|
||||
assert occurs_on(rule, datetime.date(year, 6, 15)) is True, year
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 14)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 16)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_15_across_four_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=15,
|
||||
start_date="2026-02-15",
|
||||
)
|
||||
# Feb 15 exists every year; the leap day is irrelevant.
|
||||
for year in (2026, 2027, 2028, 2029):
|
||||
assert occurs_on(rule, datetime.date(year, 2, 15)) is True, year
|
||||
|
||||
|
||||
def test_yearly_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2027-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 15)) is True
|
||||
|
||||
|
||||
def test_yearly_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15", end_date="2028-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leap-day policy: Feb 29 rule fires Feb 28 in non-leap years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_feb_29_clamps_to_feb_28_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
# 2026 non-leap: no Feb 29 — the clamp fires Feb 28.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
|
||||
# 2027 non-leap: same clamp.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
|
||||
|
||||
def test_yearly_feb_29_fires_feb_29_in_leap_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
# 2028 leap: Feb 29 exists — nominal day.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_29_four_consecutive_years() -> None:
|
||||
"""The done-condition's four-year sweep: 2026 (clamp 28), 2027
|
||||
(clamp 28), 2028 (leap: nominal 29), 2029 (clamp 28)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2029, 2, 28)) is True
|
||||
# No other day in those Februaries fires.
|
||||
for year, non_firing in (
|
||||
(2026, (27,)), (2027, (26, 27)), (2028, (27,)),
|
||||
(2029, (26, 27)),
|
||||
):
|
||||
for day in non_firing:
|
||||
assert occurs_on(rule, datetime.date(year, 2, day)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_30_is_impossible_by_model() -> None:
|
||||
"""The model rejects day_of_month 30+ for February? No — 30/31 are
|
||||
accepted (1..31) and clamp to the month end like MONTHLY_DAY."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=30,
|
||||
start_date="2027-02-28",
|
||||
)
|
||||
# Non-leap 2027: Feb 28 is the month end — the clamp fires it.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
# Leap 2028: Feb 29 is the month end — clamps there.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Day-less YEARLY rules fire on the first of the month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_without_day_fires_on_first_of_month() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, start_date="2026-01-01",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 1)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 2)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 1)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 1)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval: every N years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_interval_2_every_second_year() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True # y0
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 15)) is False # y1
|
||||
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True # y2
|
||||
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False # y3
|
||||
assert occurs_on(rule, datetime.date(2030, 6, 15)) is True # y4
|
||||
|
||||
|
||||
def test_yearly_interval_2_leap_day_every_second_year() -> None:
|
||||
"""A Feb 29 rule at interval 2 anchored 2026 fires in even years:
|
||||
2026 clamps (Feb 28), 2027 skips, 2028 hits the nominal Feb 29."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29, interval=2,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # y0 clamp
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is False # y1
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True # y2 nominal
|
||||
assert occurs_on(rule, datetime.date(2029, 2, 28)) is False # y3
|
||||
assert occurs_on(rule, datetime.date(2030, 2, 28)) is True # y4 clamp
|
||||
|
||||
|
||||
def test_yearly_interval_3_across_start_year() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=10, interval=3,
|
||||
start_date="2026-03-10",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 10)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 3, 10)) is False
|
||||
assert occurs_on(rule, datetime.date(2028, 3, 10)) is False
|
||||
assert occurs_on(rule, datetime.date(2029, 3, 10)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2029-12-31")
|
||||
assert result == [
|
||||
"2026-02-28", "2027-02-28", "2028-02-29", "2029-02-28",
|
||||
]
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_disjoint_range() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
# The rule only ever fires in June: other months are empty.
|
||||
assert occurrences_between(rule, "2027-01-01", "2027-05-31") == []
|
||||
assert occurrences_between(rule, "2027-07-01", "2027-12-31") == []
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2029-01-01", "2026-01-01")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Day of month 31 in a 30-day month: clamp consistency with MONTHLY_DAY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_june_31_clamps_to_june_30() -> None:
|
||||
"""June has 30 days: a June 31st yearly rule clamps to June 30."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=31,
|
||||
start_date="2026-06-30",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 29)) is False
|
||||
@@ -124,24 +124,6 @@ def test_missing_file_opens_fresh(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_zero_byte_existing_file_is_corrupt_and_untouched(tmp_path) -> None:
|
||||
"""A zero-byte file is neither missing nor valid: it is corrupt.
|
||||
|
||||
The leave-untouched contract applies — startup refuses (retryable)
|
||||
rather than silently initializing over whatever interrupted write
|
||||
left the empty file, and the file stays exactly zero bytes.
|
||||
"""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
db_path.write_bytes(b"")
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
assert db_path.stat().st_size == 0
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
# No sidecars appeared from the refusal path.
|
||||
assert not (tmp_path / "nestquest.db-wal").exists()
|
||||
assert not (tmp_path / "nestquest.db-shm").exists()
|
||||
|
||||
|
||||
def test_valid_file_opens_and_migrations_noop(tmp_path) -> None:
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
_valid_database(db_path)
|
||||
|
||||
Reference in New Issue
Block a user