Compare commits

..
15 changed files with 1526 additions and 3162 deletions
+35 -5
View File
@@ -10,6 +10,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN, LOGGER
from .db import NestQuestDatabase
from .store import async_get_db_path
_LOGGER = LOGGER
@@ -20,6 +22,7 @@ class NestQuestRuntimeData:
entry_id: str
options: dict[str, Any]
database: NestQuestDatabase
remove_update_listener: Callable[[], Any]
@@ -31,6 +34,14 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up NestQuest from a config entry."""
hass.data.setdefault(DOMAIN, {})
existing = hass.data[DOMAIN].get(entry.entry_id)
if existing is not None:
db = getattr(existing, "database", None)
if db is None:
db = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
existing.database = db
entry.runtime_data = existing
return True
async def _async_update_listener(
listener_hass: HomeAssistant, listener_entry: ConfigEntry
@@ -38,10 +49,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Reload the entry when its options change."""
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
database = await NestQuestDatabase(hass).open(await async_get_db_path(hass))
try:
remove_update_listener = entry.add_update_listener(_async_update_listener)
except BaseException:
await database.close()
raise
runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id,
options=dict(entry.options),
remove_update_listener=entry.add_update_listener(_async_update_listener),
database=database,
remove_update_listener=remove_update_listener,
)
entry.runtime_data = runtime_data
hass.data[DOMAIN][entry.entry_id] = runtime_data
@@ -58,10 +76,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
runtime_data = getattr(entry, "runtime_data", None)
if runtime_data is not None:
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
unload_error: BaseException | None = None
try:
if remove_update_listener is not None:
result = remove_update_listener()
if inspect.isawaitable(result):
await result
except BaseException as err:
unload_error = err
finally:
database = getattr(runtime_data, "database", None)
if database is not None:
await database.close()
if unload_error is not None:
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
raise unload_error
if getattr(entry, "runtime_data", None) is not None:
entry.runtime_data = None
return True
+422
View File
@@ -0,0 +1,422 @@
"""Async SQLite connection wrapper for the NestQuest integration."""
from __future__ import annotations
import asyncio
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from homeassistant.core import HomeAssistant
@dataclass(frozen=True)
class ExecutionResult:
"""Plain-data outcome of one statement.
``rowcount`` and ``lastrowid`` mirror the corresponding
:class:`sqlite3.Cursor` attributes; both are captured on the executor
thread so no sqlite3 object ever crosses back to the event loop.
"""
rowcount: int
lastrowid: int | None
class NestQuestDatabase:
"""Async wrapper around a single SQLite connection.
Every sqlite3 call is delegated to the Home Assistant executor through
``hass.async_add_executor_job`` so the event loop is never blocked, and
only plain Python data (row tuples and :class:`ExecutionResult`) is
returned to the event loop.
Every operation is serialized through one asyncio.Lock per wrapper. A
``transaction()`` holds that lock for its whole BEGIN..COMMIT span, so
interleaved coroutines cannot corrupt the transaction and concurrent
BEGINs are impossible; statements issued while a transaction is open
queue until it completes. Concurrent transactions on one wrapper are
not supported: a second ``transaction()`` entered while one is active
raises :class:`RuntimeError`. ``close()`` serializes on the same lock
and is idempotent.
Every connection-affecting executor job (connect, pragmas, statements,
transaction lifecycle and close) is awaited through the settlement
helper :meth:`_await_settled`: if the awaiting task is cancelled while
a job is in flight, the job still settles on the worker thread before
the cancellation propagates, so the wrapper lock is never released
while the executor thread is mid-job.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper around a Home Assistant instance."""
self._hass = hass
self._conn: sqlite3.Connection | None = None
self._rowcount: int = -1
self._lastrowid: int | None = None
self._lock: asyncio.Lock | None = None
self._lock_loop: asyncio.AbstractEventLoop | None = None
self._in_transaction = False
self._tx_task: asyncio.Task | None = None
def _get_lock(self) -> asyncio.Lock:
"""Return the per-wrapper lock, (re)bound to the running loop.
HA drives every wrapper call from one event loop, so the lock is
created once and reused; re-binding only when the running loop
differs keeps the wrapper usable from plain synchronous drivers
that run each call on a fresh loop.
"""
loop = asyncio.get_running_loop()
if self._lock is None or self._lock_loop is not loop:
self._lock = asyncio.Lock()
self._lock_loop = loop
return self._lock
@property
def connected(self) -> bool:
"""Return True while the underlying connection is open."""
return self._conn is not None
@property
def rowcount(self) -> int:
"""Row count of the most recent statement, or -1 when unset."""
return self._rowcount
@property
def lastrowid(self) -> int | None:
"""Row id of the most recent insert, or None when unset."""
return self._lastrowid
@asynccontextmanager
async def _serialized(self) -> AsyncIterator[None]:
"""Hold the wrapper lock unless the caller owns the active transaction.
A transaction holds the lock for its whole BEGIN..COMMIT span;
statements issued by the transaction's own task are already
serialized by it and must not re-acquire the non-reentrant lock.
Any other task queues on the lock until the transaction ends.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
yield
return
async with self._get_lock():
yield
async def open(self, path: Path | str) -> NestQuestDatabase:
"""Open the SQLite file with WAL journal mode and foreign keys on.
Cancellation-safe: if this call is cancelled while the connect job
is in flight, the job still settles (see :meth:`_await_settled`).
A connection the settled job created is never adopted by the
cancelled call; it is closed through a settled executor job instead
of being abandoned to garbage collection, and the cancellation
propagates afterwards, so a follow-up ``open()`` starts from a
clean state.
"""
if self._conn is not None:
return self
async with self._get_lock():
if self._conn is not None:
return self
def _open() -> sqlite3.Connection:
conn = sqlite3.connect(
path, check_same_thread=False, isolation_level=None
)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
except BaseException:
conn.close()
raise
return conn
settled, cancelled = await self._await_settled_state(
self._hass.async_add_executor_job(_open)
)
if cancelled:
if isinstance(settled, sqlite3.Connection):
def _close_abandoned() -> None:
settled.close()
await self._await_settled(
self._hass.async_add_executor_job(_close_abandoned)
)
raise asyncio.CancelledError()
self._conn = settled
return self
async def execute(
self, sql: str, parameters: Sequence[Any] = ()
) -> ExecutionResult:
"""Run one statement in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute() -> ExecutionResult:
cursor = conn.execute(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def execute_many(
self, sql: str, parameters: Sequence[Sequence[Any]]
) -> ExecutionResult:
"""Run executemany in the executor and return its plain result."""
async with self._serialized():
conn = self._require_conn()
def _execute_many() -> ExecutionResult:
cursor = conn.executemany(sql, parameters)
return ExecutionResult(cursor.rowcount, cursor.lastrowid)
result = await self._await_settled(
self._hass.async_add_executor_job(_execute_many)
)
self._rowcount = result.rowcount
self._lastrowid = result.lastrowid
return result
async def fetch_one(
self, sql: str, parameters: Sequence[Any] = ()
) -> tuple | None:
"""Run a query in the executor and return its first row, or None."""
async with self._serialized():
conn = self._require_conn()
def _fetch_one() -> tuple[tuple | None, int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchone(), cursor.rowcount, cursor.lastrowid
row, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_one)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return row
async def fetch_all(
self, sql: str, parameters: Sequence[Any] = ()
) -> list[tuple]:
"""Run a query in the executor and return all of its rows."""
async with self._serialized():
conn = self._require_conn()
def _fetch_all() -> tuple[list[tuple], int, int | None]:
cursor = conn.execute(sql, parameters)
return cursor.fetchall(), cursor.rowcount, cursor.lastrowid
rows, rowcount, lastrowid = await self._await_settled(
self._hass.async_add_executor_job(_fetch_all)
)
self._rowcount = rowcount
self._lastrowid = lastrowid
return rows
@asynccontextmanager
async def transaction(self) -> AsyncIterator[None]:
"""Run a block inside BEGIN/COMMIT, rolling back on any exception.
The wrapper lock is held for the entire BEGIN..COMMIT span, so no
other statement can interleave inside the transaction. Entering a
second transaction while one is active raises RuntimeError. A
failing COMMIT (e.g. a deferred foreign-key violation) also triggers
a ROLLBACK before the commit error propagates, so the real
connection is never left inside a stray transaction.
The lifecycle is cancellation-safe. Transaction ownership and the
wrapper lock are only released after the executor work has fully
settled, even when the owning task is cancelled mid-flight: if a
cancelled BEGIN or COMMIT still completes on the worker thread,
the corresponding ROLLBACK is shielded and awaited before any state
is cleared. A lifecycle statement that fails plus a failing
follow-up cleanup surface both errors in an ExceptionGroup.
"""
if self._in_transaction:
raise RuntimeError(
"A NestQuest transaction is already active on this "
"connection; concurrent transactions are not supported"
)
async with self._get_lock():
conn = self._require_conn()
self._in_transaction = True
self._tx_task = asyncio.current_task()
try:
def _begin() -> None:
conn.execute("BEGIN")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_begin),
"BEGIN did not settle cleanly and the follow-up "
"cleanup also raised; the transaction may need manual "
"cleanup",
)
try:
yield
except BaseException:
def _rollback() -> None:
conn.execute("ROLLBACK")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_rollback),
"ROLLBACK failed and the follow-up cleanup also "
"raised; the transaction may need manual cleanup",
)
raise
def _commit() -> None:
conn.execute("COMMIT")
await self._settle_lifecycle(
conn,
self._hass.async_add_executor_job(_commit),
"COMMIT failed and the follow-up cleanup also raised; "
"the transaction may need manual cleanup",
)
finally:
self._in_transaction = False
self._tx_task = None
async def _settle_lifecycle(
self, conn: sqlite3.Connection, job: Awaitable[Any], message: str
) -> None:
"""Run one BEGIN/ROLLBACK/COMMIT job, then force the connection clean.
The job always settles first (see :meth:`_await_settled`), so a
cancelled statement that the worker thread completed anyway is
never abandoned mid-flight. If the job raised, a follow-up
ROLLBACK — itself settled — guarantees the connection ends outside
any SQLite transaction before the error propagates. When both the
job and the cleanup raise, both errors are preserved in an
ExceptionGroup instead of the original being lost.
"""
try:
await self._await_settled(job)
except BaseException as settle_error:
errors: list[BaseException] = [settle_error]
try:
await self._ensure_no_stray_transaction(conn)
except BaseException as cleanup_error:
errors.append(cleanup_error)
if len(errors) == 2:
raise BaseExceptionGroup(message, errors) from settle_error
raise
async def _ensure_no_stray_transaction(
self, conn: sqlite3.Connection
) -> None:
"""Force the connection out of any transaction via ROLLBACK.
Runs after a failed or cancelled lifecycle statement. The worker
thread may still be finishing (or may already have completed) the
very statement whose await was cancelled, so both the check and
the ROLLBACK settle in the executor before the caller releases
transaction ownership.
"""
def _cleanup() -> bool:
if conn.in_transaction:
conn.execute("ROLLBACK")
return conn.in_transaction
stray = await self._await_settled(
self._hass.async_add_executor_job(_cleanup)
)
if stray:
raise RuntimeError(
"The SQLite connection is still inside a transaction after "
"cleanup; the transaction may need manual cleanup"
)
async def _await_settled(self, job: Awaitable[Any]) -> Any:
"""Await ``job``, letting it settle even if this task is cancelled.
Cancellation is observed, not swallowed: it is re-raised only once
the job has fully settled, so the caller can finalize against a
known connection state instead of abandoning in-flight work. The
job is wrapped in a task because a bare coroutine dies un-awaited
once its await is cancelled. A second cancellation during
settlement is absorbed the same way; if the job itself fails after
a cancellation, both errors surface in an ExceptionGroup.
"""
result, cancelled = await self._await_settled_state(job)
if cancelled:
raise asyncio.CancelledError()
return result
async def _await_settled_state(self, job: Awaitable[Any]) -> tuple[Any, bool]:
"""Await ``job`` to settlement and report ``(result, cancelled)``.
Same settlement guarantees as :meth:`_await_settled`, but returns
the settled outcome together with whether the awaiting task was
cancelled, so callers that must act on a settled result before
re-raising cancellation (e.g. closing an abandoned connection)
can do so without losing the result.
"""
cancelled = False
inner = asyncio.ensure_future(job)
while not inner.done():
try:
await asyncio.shield(inner)
except asyncio.CancelledError:
cancelled = True
try:
result = inner.result()
except BaseException as job_error:
if cancelled:
raise BaseExceptionGroup(
"executor job failed while settling after cancellation",
[asyncio.CancelledError(), job_error],
) from job_error
raise
return result, cancelled
async def close(self) -> None:
"""Close the connection in the executor; safe to call repeatedly.
Serialized on the wrapper lock, so an in-flight operation or open
transaction always completes before the connection is closed.
"""
if (
self._in_transaction
and self._tx_task is not None
and asyncio.current_task() is self._tx_task
):
raise RuntimeError(
"Cannot close the NestQuest database from inside its own "
"transaction; exit the transaction first"
)
async with self._get_lock():
conn, self._conn = self._conn, None
self._rowcount, self._lastrowid = -1, None
if conn is None:
return
def _close() -> None:
conn.close()
await self._await_settled(self._hass.async_add_executor_job(_close))
def _require_conn(self) -> sqlite3.Connection:
if self._conn is None:
raise RuntimeError(
"The NestQuest database connection is closed; call open() first"
)
return self._conn
-621
View File
@@ -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
+26 -1
View File
@@ -25,6 +25,9 @@ import asyncio
import importlib.util
import inspect
import sys
import tempfile
import weakref
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -218,13 +221,35 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry):
return entry
class _HassNamespace(SimpleNamespace):
"""SimpleNamespace subclass so the hass stub can hold weak references."""
def make_hass() -> tuple:
"""Create a hass stub plus its listener registry, mirroring HA's shape."""
hass = SimpleNamespace(
config_dir = Path(tempfile.mkdtemp(prefix="nestquest-hass-"))
hass = _HassNamespace(
data={},
config=MagicMock(),
config_entries=SimpleNamespace(async_reload=None),
)
def _cleanup_config_dir():
import shutil
shutil.rmtree(config_dir, ignore_errors=True)
weakref.finalize(hass, _cleanup_config_dir)
hass.config.path = MagicMock(
side_effect=lambda name: str(config_dir / (name or "config"))
)
def _run_executor_job(fn, *args, **kwargs):
return asyncio.get_running_loop().run_in_executor(
None, lambda: fn(*args, **kwargs)
)
hass.async_add_executor_job = _run_executor_job
registry = ListenerRegistry(hass)
async def _async_reload(entry_id: str) -> None:
+998
View File
@@ -0,0 +1,998 @@
"""Tests for db.py, the async SQLite connection wrapper of NestQuest."""
from __future__ import annotations
import ast
import asyncio
import sqlite3
import threading
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from conftest import make_hass
from custom_components.nestquest import db as db_module
from custom_components.nestquest.db import NestQuestDatabase
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _make_hass_mock():
hass = MagicMock()
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
return hass
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
_run(database.open(path))
return database, hass
# ---------------------------------------------------------------------------
# open(): pragmas, executor delegation, idempotence
# ---------------------------------------------------------------------------
def test_open_enables_wal_journal_mode(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "wal.db")
row = _run(database.fetch_one("PRAGMA journal_mode"))
assert row is not None and str(row[0]).lower() == "wal"
_run(database.close())
def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "fk.db")
row = _run(database.fetch_one("PRAGMA foreign_keys"))
assert row is not None and int(row[0]) == 1
_run(database.close())
def test_open_delegates_connection_to_executor(tmp_path) -> None:
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
_run(database.open(tmp_path / "exec.db"))
first_call = hass.async_add_executor_job.call_args_list[0]
assert callable(first_call.args[0])
_run(database.close())
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
path = tmp_path / "once.db"
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
first = _run(database.open(path))
second = _run(database.open(path))
assert first is database
assert second is database
assert database.connected is True
opens = [
c
for c in hass.async_add_executor_job.call_args_list
if getattr(c.args[0], "__name__", "") == "_open"
]
assert len(opens) == 1
_run(database.close())
def test_open_accepts_str_path(tmp_path) -> None:
database, _hass = _open_db(str(tmp_path / "str.db"))
assert database.connected is True
_run(database.close())
# ---------------------------------------------------------------------------
# Executor delegation: every sqlite3 call happens off the event loop
# ---------------------------------------------------------------------------
def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
"""Each wrapper call must go via hass.async_add_executor_job."""
database, hass = _open_db(tmp_path / "route.db")
calls_before = hass.async_add_executor_job.await_count
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("a",)))
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]))
row = _run(database.fetch_one("SELECT name FROM t WHERE id = 1"))
rows = _run(database.fetch_all("SELECT name FROM t ORDER BY id"))
assert row == ("a",)
assert rows == [("a",), ("b",), ("c",)]
assert hass.async_add_executor_job.await_count == calls_before + 5
_run(database.close())
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
"""The executor surface is awaited per wrapper call on a live loop."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "loop.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.fetch_one("SELECT 1")
await database.fetch_all("SELECT 1")
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(1,)])
async with database.transaction():
pass
assert hass.async_add_executor_job.await_count == 7
assert all(callable(c.args[0]) for c in hass.async_add_executor_job.call_args_list)
await database.close()
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
hass = _make_hass_mock()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "plain.db")
result = await database.execute(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
)
assert not isinstance(result, (sqlite3.Connection, sqlite3.Cursor))
insert = await database.execute("INSERT INTO t (name) VALUES (?)", ("a",))
assert not isinstance(insert, (sqlite3.Connection, sqlite3.Cursor))
assert insert.rowcount == 1
assert insert.lastrowid == 1
many = await database.execute_many(
"INSERT INTO t (name) VALUES (?)", [("b",), ("c",)]
)
assert not isinstance(many, (sqlite3.Connection, sqlite3.Cursor))
assert many.rowcount == 2
row = await database.fetch_one("SELECT name FROM t WHERE id = 1")
assert row == ("a",)
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
assert rows == [("a",), ("b",), ("c",)]
for value in (*rows, row):
assert not isinstance(value, sqlite3.Cursor)
await database.close()
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
"""With the conftest hass (real run_in_executor), queries still succeed."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "real.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
assert await database.fetch_one("SELECT 1") == (1,)
await database.close()
async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) -> None:
"""Every real sqlite3 call must run on a non-event-loop thread.
sqlite3.connect is instrumented so a proxy wraps the connection and
every cursor it hands out; each proxied call records the thread it ran
on. A recorded thread id equal to the event-loop thread would mean a
sqlite3 call crossed the executor boundary.
"""
loop_thread = threading.get_ident()
recorded: list[int] = []
guard = threading.Lock()
def _note() -> None:
with guard:
recorded.append(threading.get_ident())
class _Proxy:
"""Generic proxy recording the thread of every attribute call."""
def __init__(self, target: Any) -> None:
object.__setattr__(self, "_target", target)
def __getattr__(self, name: str):
attr = getattr(object.__getattribute__(self, "_target"), name)
if not callable(attr):
return attr
def _wrapped(*args, **kwargs):
_note()
result = attr(*args, **kwargs)
if isinstance(result, sqlite3.Cursor):
return _Proxy(result)
return result
return _wrapped
real_connect = sqlite3.connect
connect_threads: list[int] = []
def _traced_connect(*args, **kwargs):
_note()
connect_threads.append(threading.get_ident())
return _Proxy(real_connect(*args, **kwargs))
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "threads.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
await database.execute_many("INSERT INTO t (id) VALUES (?)", [(2,), (3,)])
assert await database.fetch_one("SELECT id FROM t WHERE id = 1") == (1,)
assert await database.fetch_all("SELECT id FROM t") == [(1,), (2,), (3,)]
async with database.transaction():
await database.execute("INSERT INTO t (id) VALUES (?)", (4,))
await database.close()
assert recorded, "expected at least one recorded sqlite3 call"
assert len(connect_threads) == 1, (
f"expected exactly one traced sqlite3.connect, got {len(connect_threads)}"
)
assert loop_thread not in recorded, (
f"sqlite3 call ran on the event-loop thread {loop_thread}; "
f"recorded threads: {sorted(set(recorded))}"
)
# ---------------------------------------------------------------------------
# Source scan: sqlite3.* only inside executor-delegated functions
# ---------------------------------------------------------------------------
def _iter_with_scope(node, chain):
"""Yield (node, enclosing-function chain), depth-first."""
for child in ast.iter_child_nodes(node):
yield child, chain
next_chain = chain + (child,) if isinstance(
child, (ast.FunctionDef, ast.AsyncFunctionDef)
) else chain
yield from _iter_with_scope(child, next_chain)
def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
A function is executor-delegating when it (or, for nested closures, any of
its enclosing functions) passes work to ``hass.async_add_executor_job``.
"""
source = Path(db_module.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(db_module.__file__))
delegated = {
node.name
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and any(
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Attribute)
and sub.func.attr == "async_add_executor_job"
for sub in ast.walk(node)
)
}
assert delegated, "expected executor-delegated functions in db.py"
violations = []
for node, chain in _iter_with_scope(tree, ()):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "sqlite3"
and not any(f.name in delegated for f in chain)
):
violations.append((node.lineno, node.func.attr))
assert violations == [], (
f"Direct sqlite3 calls outside executor-delegated functions: {violations}"
)
def test_package_wide_sqlite3_scan_excludes_only_db_module() -> None:
"""The package-wide store scan exempts exactly the db module."""
pkg_dir = Path(db_module.__file__).parent
scanned = [p.name for p in pkg_dir.glob("*.py") if p.name != "db.py"]
assert scanned, "expected other package modules to be scanned"
assert "db.py" not in scanned
# ---------------------------------------------------------------------------
# close(): idempotence and post-close errors
# ---------------------------------------------------------------------------
def test_close_is_idempotent(tmp_path) -> None:
database, hass = _open_db(tmp_path / "idem.db")
_run(database.close())
assert database.connected is False
_run(database.close())
assert database.connected is False
assert hass.async_add_executor_job.await_count == 2
assert database.rowcount == -1
assert database.lastrowid is None
def test_operations_after_close_raise_clear_error(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "closed.db")
_run(database.close())
async def _txn():
async with database.transaction():
pass
with pytest.raises(RuntimeError, match="closed"):
_run(database.execute("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.fetch_one("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.fetch_all("SELECT 1"))
with pytest.raises(RuntimeError, match="closed"):
_run(database.execute_many("SELECT 1", []))
with pytest.raises(RuntimeError, match="closed"):
_run(_txn())
# ---------------------------------------------------------------------------
# Transactions: commit on success, rollback on exception
# ---------------------------------------------------------------------------
def test_transaction_commits_on_success(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "commit.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
async def _block():
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
_run(_block())
assert _run(database.fetch_all("SELECT name FROM t")) == [("kept",)]
_run(database.close())
def test_transaction_rolls_back_on_exception(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "rollback.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("before",)))
async def _block():
with pytest.raises(ValueError, match="boom"):
async with database.transaction():
await database.execute(
"INSERT INTO t (name) VALUES (?)", ("dropped",)
)
raise ValueError("boom")
_run(_block())
assert _run(database.fetch_all("SELECT name FROM t")) == [("before",)]
_run(database.close())
async def test_commit_failure_rolls_back_and_next_transaction_works(
tmp_path,
) -> None:
"""A COMMIT failure rolls back and leaves the wrapper reusable.
With PRAGMA defer_foreign_keys=ON a violating insert succeeds inside
the transaction and the foreign-key violation only surfaces at COMMIT.
The commit error must propagate, the real connection must end up
outside any transaction, and the next transaction() plus statement
must run cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "deferred.db")
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
await database.execute(
"CREATE TABLE child ("
"id INTEGER PRIMARY KEY, "
"parent_id INTEGER NOT NULL REFERENCES parent(id))"
)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
async with database.transaction():
await database.execute("PRAGMA defer_foreign_keys=ON")
await database.execute(
"INSERT INTO child (parent_id) VALUES (?)", (999,)
)
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
assert await database.fetch_all("SELECT COUNT(*) FROM child") == [(0,)]
async with database.transaction():
await database.execute("INSERT INTO parent (id) VALUES (?)", (1,))
await database.execute(
"INSERT INTO child (parent_id) VALUES (?)", (1,)
)
assert await database.fetch_all("SELECT COUNT(*) FROM child") == [(1,)]
await database.close()
async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
"""NQ-PR12-010: cancel during open() must close the settled connection.
The _open executor job is gated: the open task is cancelled while the
connect is still in flight, then the worker is allowed to complete it
anyway. The settled connection must never be adopted, must be closed
through a settled executor-backed close before the cancellation
propagates, and a follow-up open() must succeed cleanly.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
open_started = asyncio.Event()
release_open = asyncio.Event()
jobs: list[str] = []
captured: dict[str, Any] = {}
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
name = getattr(fn, "__name__", "")
jobs.append(name)
if name == "_open":
open_started.set()
await release_open.wait()
result = await real_add(fn, *args, **kwargs)
if name == "_open":
captured["conn"] = result
return result
hass.async_add_executor_job = _gated_add_executor_job
open_task = asyncio.create_task(database.open(tmp_path / "cancel-open.db"))
await open_started.wait()
open_task.cancel()
await asyncio.sleep(0.05)
assert database._conn is None, (
"the cancelled open adopted a connection that was still settling"
)
release_open.set()
with pytest.raises(asyncio.CancelledError):
await open_task
assert database.connected is False
assert "_close_abandoned" in jobs, (
"the abandoned connection was not closed via an executor job"
)
conn = captured.get("conn")
assert conn is not None
def _probe() -> str:
try:
conn.execute("SELECT 1")
except sqlite3.ProgrammingError:
return "closed"
return "open"
assert await real_add(_probe) == "closed"
hass.async_add_executor_job = real_add
await database.open(tmp_path / "cancel-open.db")
assert database.connected is True
assert await database.fetch_one("SELECT 1") == (1,)
await database.close()
async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
"""NQ-PR12-007: cancel during BEGIN must not leak a stray transaction.
The BEGIN executor job is gated: the transaction task is cancelled
while BEGIN is still in flight, then the worker is allowed to
complete it anyway. The wrapper must keep transaction ownership
until the submitted work settles, roll the completed BEGIN back,
clear its state, and stay usable for the next transaction.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-begin.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
begin_started = asyncio.Event()
release_begin = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_begin":
begin_started.set()
await release_begin.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
txn_task = asyncio.create_task(_txn())
await begin_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled BEGIN was still in flight"
)
release_begin.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",)
]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",),
("kept",),
]
await database.close()
async def test_cancel_during_commit_settles_then_propagates_cancellation(
tmp_path,
) -> None:
"""NQ-PR12-007: cancel during COMMIT must not mask the cancellation.
COMMIT is gated; the task is cancelled while it is in flight and the
worker then completes it anyway. The cancellation must propagate as
CancelledError (not be converted by a doomed ROLLBACK of an already
committed transaction), the connection must end outside any
transaction, and the wrapper must stay usable.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-commit.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
commit_started = asyncio.Event()
release_commit = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_commit":
commit_started.set()
await release_commit.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
txn_task = asyncio.create_task(_txn())
await commit_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled COMMIT was still in flight"
)
release_commit.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t") == [("kept",)]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("again",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("kept",),
("again",),
]
await database.close()
async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
tmp_path,
) -> None:
"""NQ-PR12-009: cancel during an in-transaction statement must not race ROLLBACK.
The in-transaction INSERT executor job is gated: the transaction task
is cancelled while the statement is still in flight, then the worker
is allowed to complete it anyway. The ROLLBACK must only run after
the statement has settled — while the statement is in flight the
transaction is still open — and the cancelled statement's write must
not persist.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "cancel-execute.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
insert_started = asyncio.Event()
release_insert = asyncio.Event()
real_add = hass.async_add_executor_job
async def _gated_add_executor_job(fn, *args, **kwargs):
if getattr(fn, "__name__", "") == "_execute":
insert_started.set()
await release_insert.wait()
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _gated_add_executor_job
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("lost",))
txn_task = asyncio.create_task(_txn())
await insert_started.wait()
txn_task.cancel()
await asyncio.sleep(0.05)
assert database._in_transaction is True, (
"ownership was released while the cancelled statement was in flight"
)
assert database._conn is not None
assert database._conn.in_transaction is True, (
"ROLLBACK ran before the cancelled statement settled"
)
release_insert.set()
with pytest.raises(asyncio.CancelledError):
await txn_task
assert database._in_transaction is False
assert database._tx_task is None
assert database._conn is not None
assert database._conn.in_transaction is False
hass.async_add_executor_job = real_add
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",)
]
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
assert await database.fetch_all("SELECT name FROM t ORDER BY id") == [
("before",),
("kept",),
]
await database.close()
async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None:
"""NQ-PR12-008: both errors survive when COMMIT and ROLLBACK both fail.
The COMMIT executor call is forced to fail, and the follow-up
cleanup ROLLBACK is forced to fail as well. The raised error must
keep both original exceptions inspectable instead of discarding the
commit failure behind the rollback failure.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "both-fail.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
commit_failure = sqlite3.OperationalError("commit exploded")
rollback_failure = sqlite3.OperationalError("rollback exploded")
real_add = hass.async_add_executor_job
async def _failing_add_executor_job(fn, *args, **kwargs):
name = getattr(fn, "__name__", "")
if name == "_commit":
raise commit_failure
if name == "_cleanup":
raise rollback_failure
return await real_add(fn, *args, **kwargs)
hass.async_add_executor_job = _failing_add_executor_job
with pytest.raises(BaseExceptionGroup) as exc_info:
async with database.transaction():
pass
assert commit_failure in exc_info.value.exceptions
assert rollback_failure in exc_info.value.exceptions
assert database._in_transaction is False
assert database._tx_task is None
await database.close()
async def test_transaction_persists_across_connections(tmp_path) -> None:
"""A committed transaction is visible to a brand-new connection."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "persist.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
await database.close()
other = NestQuestDatabase(hass)
await other.open(tmp_path / "persist.db")
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
await other.close()
# ---------------------------------------------------------------------------
# Serialization: one lock per wrapper, no cross-commit interleaving
# ---------------------------------------------------------------------------
async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
"""Real-executor proof: nothing commits inside another transaction's span.
A transaction runner repeatedly counts rows, inserts, and counts again
inside BEGIN..COMMIT; the count must grow by exactly one, proving no
other writer committed in between even though several writer tasks fire
statements concurrently on real executor threads. Statements issued
while a transaction is open queue behind it on the wrapper lock.
"""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "serial.db")
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
tx_rounds, writers, writer_rounds = 6, 5, 8
total = tx_rounds + writers * writer_rounds
async def _tx_runner() -> None:
for rnd in range(tx_rounds):
async with database.transaction():
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
seen = rows[0][0] if rows else -1
await database.execute(
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("tx", rnd)
)
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
grown = rows[0][0] if rows else -1
if grown != seen + 1:
raise AssertionError(
f"interleaved commit: count {seen} -> {grown} "
f"in tx round {rnd}"
)
async def _writer(worker: int) -> None:
for rnd in range(writer_rounds):
await database.execute(
"INSERT INTO t (kind, seq) VALUES (?, ?)", ("w", worker)
)
await asyncio.gather(
_tx_runner(), *(_writer(w) for w in range(writers))
)
rows = await database.fetch_all("SELECT COUNT(*) FROM t")
assert rows[0][0] == total, f"expected {total} rows, got {rows[0][0]}"
await database.close()
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
"""A second transaction caller is rejected with a clear error."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "reject.db")
await database.execute("CREATE TABLE t (id INTEGER)")
started = asyncio.Event()
async def _first() -> None:
async with database.transaction():
started.set()
await asyncio.sleep(0.05)
first = asyncio.create_task(_first())
await started.wait()
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
pass
await first
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
assert await database.fetch_one("SELECT id FROM t") == (1,)
await database.close()
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
"""A statement issued during an open transaction queues behind it."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "block.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
order: list[str] = []
started = asyncio.Event()
release = asyncio.Event()
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("in_tx",))
order.append("tx-start")
started.set()
await release.wait()
order.append("tx-end")
async def _outsider() -> None:
await started.wait()
await database.execute("INSERT INTO t (name) VALUES (?)", ("after",))
order.append("outside")
txn_task = asyncio.create_task(_txn())
await started.wait()
outsider_task = asyncio.create_task(_outsider())
await asyncio.sleep(0.05)
assert order == ["tx-start"], f"outsider ran inside transaction: {order}"
release.set()
await asyncio.gather(txn_task, outsider_task)
assert order == ["tx-start", "tx-end", "outside"]
rows = await database.fetch_all("SELECT name FROM t ORDER BY id")
assert rows == [("in_tx",), ("after",)]
await database.close()
async def test_concurrent_transaction_is_rejected_with_clear_error(
tmp_path,
) -> None:
"""A second concurrent transaction() gets a clear RuntimeError."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "concurrent-tx.db")
await database.execute("CREATE TABLE t (id INTEGER)")
started = asyncio.Event()
async def _first() -> None:
async with database.transaction():
started.set()
await asyncio.sleep(0.05)
first = asyncio.create_task(_first())
await started.wait()
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
pass
await first
await database.close()
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
"""close() serializes: an open transaction finishes before shutdown."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "closerace.db")
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
started = asyncio.Event()
release = asyncio.Event()
async def _txn() -> None:
async with database.transaction():
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
started.set()
await release.wait()
txn_task = asyncio.create_task(_txn())
await started.wait()
close_task = asyncio.create_task(database.close())
await asyncio.sleep(0.05)
assert database.connected is True, "close() raced an open transaction"
release.set()
await asyncio.gather(txn_task, close_task)
assert database.connected is False
witness = NestQuestDatabase(hass)
await witness.open(tmp_path / "closerace.db")
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
await witness.close()
async def test_close_during_transaction_from_same_task_is_rejected(
tmp_path,
) -> None:
"""close() inside its own transaction body raises instead of deadlocking."""
hass, _registry = make_hass()
database = NestQuestDatabase(hass)
await database.open(tmp_path / "selfclose.db")
await database.execute("CREATE TABLE t (id INTEGER)")
with pytest.raises(RuntimeError, match="transaction"):
async with database.transaction():
await database.close()
assert database.connected is True
await database.close()
# ---------------------------------------------------------------------------
# rowcount / lastrowid surface
# ---------------------------------------------------------------------------
def test_rowcount_and_lastrowid_before_any_operation(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "unset.db")
assert database.rowcount == -1
assert database.lastrowid is None
_run(database.close())
def test_execute_exposes_rowcount_and_lastrowid(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "counts.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("x",)))
assert database.rowcount == 1
assert database.lastrowid == 1
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("y",)))
assert database.rowcount == 1
assert database.lastrowid == 2
_run(database.close())
def test_execute_many_exposes_rowcount(tmp_path) -> None:
database, _hass = _open_db(tmp_path / "many.db")
_run(database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"))
_run(database.execute_many("INSERT INTO t (name) VALUES (?)", [("a",), ("b",)]))
assert database.rowcount == 2
_run(database.execute("INSERT INTO t (name) VALUES (?)", ("c",)))
assert database.lastrowid == 3
_run(database.close())
# ---------------------------------------------------------------------------
# Lifecycle wiring: opened once per entry, closed on unload
# ---------------------------------------------------------------------------
async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
runtime = entry.runtime_data
assert runtime.database is not None
assert runtime.database.connected is True
await async_unload_entry(hass, entry)
async def test_unload_entry_closes_database(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
assert database.connected is True
assert await async_unload_entry(hass, entry) is True
assert database.connected is False
with pytest.raises(RuntimeError, match="closed"):
await database.fetch_one("SELECT 1")
async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
first = entry.runtime_data.database
await async_setup_entry(hass, entry)
second = entry.runtime_data.database
assert first is second
assert first.connected is True
await async_unload_entry(hass, entry)
async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None:
from conftest import wire_entry_to_registry
from custom_components.nestquest import async_setup_entry, async_unload_entry
entry = wire_entry_to_registry(make_entry(), hass.registry)
seen: list[NestQuestDatabase] = []
for _ in range(3):
await async_setup_entry(hass, entry)
runtime = entry.runtime_data
assert runtime.database.connected is True
seen.append(runtime.database)
await async_unload_entry(hass, entry)
assert runtime.database.connected is False
assert len(set(seen)) == 3
assert hass.data == {}
+41 -1
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
import pytest
from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
@@ -196,4 +198,42 @@ async def test_setup_awaitable_listener_removal_supported() -> None:
assert result is True
assert removed == [True]
assert hass.data == {}
assert entry.runtime_data is None
assert entry.runtime_data is None
async def test_setup_closes_database_when_listener_registration_fails() -> None:
"""A listener-registration failure must not leak the opened database."""
from custom_components.nestquest.db import NestQuestDatabase
class _Boom(Exception):
pass
entry = make_config_entry(entry_id="listener-boom")
entry.add_update_listener = lambda listener: (_ for _ in ()).throw(_Boom())
hass, _registry = make_hass()
with pytest.raises(_Boom):
await async_setup_entry(hass, entry)
assert hass.data.get(DOMAIN, {}) == {}
assert not hasattr(entry, "runtime_data")
async def test_unload_closes_database_even_when_listener_removal_raises() -> None:
"""Unload removes the listener, closes the DB in a finally, then raises."""
from custom_components.nestquest.db import NestQuestDatabase
class _RemoveBoom(Exception):
pass
def _remover():
raise _RemoveBoom()
entry = make_config_entry(entry_id="remove-boom")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
database = entry.runtime_data.database
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert database.connected is False, "database leaked despite remover error"
assert entry.runtime_data is None
assert DOMAIN not in hass.data
-361
View File
@@ -1,361 +0,0 @@
"""Window-bound enforcement across every rule type (start_date/end_date).
Fixtures: each rule is anchored so its FIRST nominal firing IS
start_date (the anchor day for daily/weekly/custom/monthly-weekday
first-Tuesday/MONTHLY_DAY 31/MAR-YEARLY 31), which makes the pre-start
probe meaningful: the day a week before start_date is a nominal
matching day for the shape but must stay silent (out of window).
"""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def _tuesday(year: int, month: int, nth: int) -> datetime.date:
"""The nth Tuesday of the month (nth >= 1)."""
day = datetime.date(year, month, 1)
while day.weekday() != 1:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _friday(year: int, month: int, nth: int) -> datetime.date:
day = datetime.date(year, month, 1)
while day.weekday() != 4:
day += datetime.timedelta(days=1)
return day + datetime.timedelta(weeks=nth - 1)
def _month_end(year: int, month: int) -> datetime.date:
if month == 12:
return datetime.date(year, 12, 31)
return datetime.date(year, month + 1, 1) - datetime.timedelta(days=1)
#: One representative rule per type. Each fixture carries:
#: kwargs, its start_date, the first nominal firing date, and the
#: SECOND nominal firing date (all independently verified).
#: - DAILY: start Mar 1; fires daily; second firing Mar 2.
#: - WEEKLY (Tuesdays): start Tue Mar 3; second firing Tue Mar 10.
#: - CUSTOM_DAYS (Wed + Fri): start Wed Mar 4; second firing Fri Mar 6.
#: - MONTHLY_DAY (31st): start Tue Mar 31; second firing Apr 30 (clamp).
#: - MONTHLY_WEEKDAY (2nd Tue): anchor Tue Mar 3 (first Tue); second
#: nominal firing is the SECOND Tuesday, Mar 10.
#: - YEARLY (Mar 31): start Tue Mar 31; second firing Mar 31 2027.
BOUNDARY_RULES = [
{
"kwargs": {"rule_type": RuleType.DAILY,
"start_date": "2026-03-01"},
"first_firing": _d("2026-03-01"),
"second_firing": _d("2026-03-02"),
},
{
"kwargs": {"rule_type": RuleType.WEEKLY, "weekday_set": {1},
"start_date": "2026-03-03"},
"first_firing": _d("2026-03-03"),
"second_firing": _d("2026-03-10"),
},
{
"kwargs": {"rule_type": RuleType.CUSTOM_DAYS,
"weekday_set": {2, 4}, "start_date": "2026-03-04"},
"first_firing": _d("2026-03-04"),
"second_firing": _d("2026-03-06"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_DAY,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2026-04-30"),
},
{
"kwargs": {"rule_type": RuleType.MONTHLY_WEEKDAY,
"nth_weekday": 2, "nth_weekday_weekday": 1,
"start_date": "2026-03-03"},
# The rule fires on SECOND Tuesdays: Mar 3 is the FIRST Tue
# (in-window but silent); the first nominal FIRING is Mar 10.
"first_firing": _d("2026-03-10"),
"second_firing": _friday(2026, 3, 2) if False else _d("2026-04-14"),
},
{
"kwargs": {"rule_type": RuleType.YEARLY, "month": 3,
"day_of_month": 31, "start_date": "2026-03-31"},
"first_firing": _d("2026-03-31"),
"second_firing": _d("2027-03-31"),
},
]
# ---------------------------------------------------------------------------
# start_date bound: nothing fires before it, for EVERY rule type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_no_firing_before_start_date(index) -> None:
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
before_start = _d(rule.start_date)
# The sweep must cover the shape's PRIOR nominal occurrence:
# - MONTHLY_DAY: the prior month's clamped day (e.g. Feb 28/31)
# - YEARLY: the prior year's nominal day
# - WEEKLY/CUSTOM_DAYS: the prior week's matching weekday
# - MONTHLY_WEEKDAY: the prior month's nth weekday
if rule.rule_type is RuleType.MONTHLY_DAY:
sweep_from = _month_end(2026, 2) # Feb 28, a nominal clamp day
elif rule.rule_type is RuleType.YEARLY:
sweep_from = datetime.date(
_d(rule.start_date).year - 1, rule.month, rule.day_of_month
)
elif rule.rule_type is RuleType.MONTHLY_WEEKDAY:
sweep_from = _tuesday(2026, 2, 2) # prior month's 2nd Tuesday
elif rule.rule_type is RuleType.WEEKLY:
sweep_from = before_start - datetime.timedelta(days=7)
elif rule.rule_type is RuleType.CUSTOM_DAYS:
sweep_from = before_start - datetime.timedelta(days=7)
else:
sweep_from = before_start - datetime.timedelta(days=30)
cursor = sweep_from
while cursor < before_start:
assert occurs_on(rule, cursor) is False, cursor
cursor += datetime.timedelta(days=1)
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
def test_first_nominal_firing_is_on_or_after_start(index) -> None:
"""The first nominal firing must actually fire: the window must not
exclude a date at or after start_date."""
fixture = BOUNDARY_RULES[index]
rule = ScheduleRule(**fixture["kwargs"])
first = fixture["first_firing"]
assert occurs_on(rule, first) is True, first
# ---------------------------------------------------------------------------
# end_date bound: nothing fires after it
# ---------------------------------------------------------------------------
def test_no_firing_after_end_date_all_types() -> None:
window_end = _d("2026-09-30")
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-09-30", **fixture["kwargs"]
)
# Sweep every day in the week after window_end: none may fire,
# regardless of whether that date would nominally match.
for offset in range(1, 8):
after = window_end + datetime.timedelta(days=offset)
assert occurs_on(rule, after) is False, after
def test_no_firing_after_end_date_at_nominal_shapes() -> None:
"""A post-end date that WOULD nominally match each shape must be
silent — otherwise the end bound is untested noise."""
rule_monthly = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31", end_date="2026-04-30",
)
# Apr 30 is INSIDE the window (fires: clamp); May 31 would nominally
# match but is after end_date: silent.
assert occurs_on(rule_monthly, _d("2026-04-30")) is True
assert occurs_on(rule_monthly, _d("2026-05-31")) is False
rule_yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31", end_date="2027-03-31",
)
# Mar 31 2027 is inside; Mar 31 2028 would match but is past end.
assert occurs_on(rule_yearly, _d("2027-03-31")) is True
assert occurs_on(rule_yearly, _d("2028-03-31")) is False
rule_weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03", end_date="2026-10-05",
)
# Tue Oct 6 would nominally match but is after end_date (Oct 5 is a
# Monday, not in the set).
assert occurs_on(rule_weekly, _d("2026-09-29")) is True
assert occurs_on(rule_weekly, _d("2026-10-06")) is False
rule_monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03",
end_date="2026-10-05",
)
# The next nominal second Tuesday (Oct 13) is past end_date: silent.
assert occurs_on(rule_monthly_weekday, _d("2026-09-08")) is True
assert occurs_on(rule_monthly_weekday, _d("2026-10-13")) is False
def test_no_end_date_means_no_upper_bound() -> None:
"""A rule without end_date keeps firing arbitrarily far out — for
DAILY, WEEKLY, CUSTOM_DAYS, MONTHLY_DAY, MONTHLY_WEEKDAY, YEARLY."""
daily = ScheduleRule(rule_type=RuleType.DAILY,
start_date="2026-03-01")
assert occurs_on(daily, _d("2036-03-01")) is True
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1},
start_date="2026-03-03",
)
# 2076-03-03 is 50 years to the day after the anchor and a Tuesday.
assert occurs_on(weekly, _d("2076-03-03")) is True
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={4},
start_date="2026-03-06", # a Friday
)
assert occurs_on(custom, _d("2026-03-06")) is True
# The first Friday of March 2046 is Mar 2; Mar 6 is a Tuesday.
assert occurs_on(custom, _d("2046-03-01")) is False # not in set
assert occurs_on(custom, _d("2046-03-02")) is True
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
assert occurs_on(monthly_day, datetime.date(2036, 3, 15)) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, start_date="2026-03-04",
)
# First Wednesday of March 2036.
d = datetime.date(2036, 3, 1)
while d.weekday() != 2:
d += datetime.timedelta(days=1)
assert occurs_on(monthly_weekday, d) is True
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
start_date="2026-03-31",
)
assert occurs_on(yearly, datetime.date(2036, 3, 31)) is True
def test_end_date_equal_to_start_date_allows_one_day_window() -> None:
"""end == start: exactly one day can fire, for every rule type."""
daily = ScheduleRule(
rule_type=RuleType.DAILY, start_date="2026-03-15",
end_date="2026-03-15",
)
assert occurs_on(daily, _d("2026-03-15")) is True
assert occurs_on(daily, _d("2026-03-14")) is False
assert occurs_on(daily, _d("2026-03-16")) is False
weekly = ScheduleRule(
rule_type=RuleType.WEEKLY, weekday_set={1}, # 2026-03-10 is a Tue
start_date="2026-03-10", end_date="2026-03-10",
)
# The window is a SINGLE day (end == start, a Tuesday in-set):
# it fires, and the following Tuesday is out of window.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(weekly, _d("2026-03-10")) is True
assert occurs_on(weekly, _d("2026-03-17")) is False
custom = ScheduleRule(
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 4},
start_date="2026-03-11", end_date="2026-03-11",
)
# Mar 11 2026 is a Wednesday: fires. Next Wed (18) is out of window.
assert _d("2026-03-11").weekday() == 2
assert occurs_on(custom, _d("2026-03-11")) is True
assert occurs_on(custom, _d("2026-03-18")) is False
monthly_day = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(monthly_day, _d("2026-03-15")) is True
monthly_weekday = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-10",
end_date="2026-03-10",
)
# Mar 10 2026 is the second Tuesday: the one-day window admits it.
assert _d("2026-03-10").weekday() == 1
assert occurs_on(monthly_weekday, _d("2026-03-10")) is True
assert occurs_on(monthly_weekday, _d("2026-04-14")) is False
yearly = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=15,
start_date="2026-03-15", end_date="2026-03-15",
)
assert occurs_on(yearly, _d("2026-03-15")) is True
assert occurs_on(yearly, _d("2027-03-15")) is False
# ---------------------------------------------------------------------------
# end_date before start_date is rejected at construction, every type
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"kwargs",
[
{"rule_type": RuleType.DAILY, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.WEEKLY, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.CUSTOM_DAYS, "weekday_set": {0},
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_DAY, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
{"rule_type": RuleType.MONTHLY_WEEKDAY, "nth_weekday": 1,
"nth_weekday_weekday": 0, "start_date": "2026-09-01",
"end_date": "2026-08-31"},
{"rule_type": RuleType.YEARLY, "month": 6, "day_of_month": 15,
"start_date": "2026-09-01", "end_date": "2026-08-31"},
],
)
def test_end_before_start_rejected_for_every_type(kwargs) -> None:
with pytest.raises(RuleValidationError, match="on or after"):
ScheduleRule(**kwargs)
# ---------------------------------------------------------------------------
# occurrences_between respects the window for every type
# ---------------------------------------------------------------------------
def test_occurrences_between_respects_end_bound_all_types() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"]
)
result = occurrences_between(rule, "2026-01-01", "2026-12-31")
# Every result inside the queried range, none beyond June.
assert all(date <= "2026-06-30" for date in result), result
assert all(
date >= fixture["kwargs"]["start_date"] for date in result
), result
def test_occurrences_between_empty_when_range_before_start() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(**fixture["kwargs"])
assert occurrences_between(rule, "2026-01-01", "2026-02-28") == []
def test_occurrences_between_empty_when_range_after_end() -> None:
for fixture in BOUNDARY_RULES:
rule = ScheduleRule(
end_date="2026-06-30", **fixture["kwargs"],
)
result = occurrences_between(rule, "2026-10-01", "2026-12-31")
assert result == []
-289
View File
@@ -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"]
-485
View File
@@ -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)
-301
View File
@@ -1,301 +0,0 @@
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_month_end,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings
# ---------------------------------------------------------------------------
def test_monthly_day_15_fires_on_the_15th() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
for month in range(1, 13):
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
def test_monthly_day_never_fires_on_other_days() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
start_date="2026-01-10",
)
# Every non-10th day of a short month must be False.
for day in range(1, 30):
if day != 10:
assert occurs_on(
rule, datetime.date(2026, 1, day)
) is False, f"Jan {day}"
def test_monthly_day_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-03-15",
)
# Feb 15 precedes the start: no firing even though the day matches.
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
def test_monthly_day_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15", end_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
# ---------------------------------------------------------------------------
# Month-end clamp policy
# ---------------------------------------------------------------------------
def test_month_end_helper_is_leap_aware() -> None:
assert _month_end(2026, 1) == 31
assert _month_end(2026, 4) == 30
assert _month_end(2026, 2) == 28 # non-leap
assert _month_end(2028, 2) == 29 # leap
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
assert _month_end(2026, 12) == 31
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Months with a 31st: fire on the 31st.
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
(10, 31), (12, 31)):
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
f"2026-{month:02d}-{day:02d}"
)
# Months WITHOUT a 31st: fire on the month's last day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
# The day BEFORE the clamp is not a firing.
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
def test_day_30_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2026-01-30",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# Months with a 30th: nominal day.
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2026-01-29",
)
# Non-leap 2027: Feb 28 is the clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
# Leap 2028: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_day_30_feb_leap_year_clamps_to_29() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2027-01-30",
)
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# ---------------------------------------------------------------------------
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
# ---------------------------------------------------------------------------
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
"""Every (month, day) in ``rule``'s year the engine says fires."""
hits = []
for month in range(1, 13):
for day in range(1, _month_end(year, month) + 1):
if occurs_on(rule, datetime.date(year, month, day)):
hits.append((month, day))
return hits
def test_full_year_day_31_sweep_2026() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
# Exactly one firing per month, twelve total.
hits = _sweep_monthly(rule, 2026)
assert len(hits) == 12
by_month = dict(hits)
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
assert by_month[2] == 28 # 2026 Feb is non-leap
assert by_month[4] == 30
# Each month has exactly one firing.
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_31_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2028-01-31",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap February clamps to the 29th
assert by_month[4] == 30
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_30_sweep_2028() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
start_date="2028-01-30",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb clamps to 29
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2027_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2027-01-29",
)
hits = _sweep_monthly(rule, 2027)
by_month = dict(hits)
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
months = [month for month, _ in hits]
assert months == list(range(1, 13))
def test_full_year_day_29_sweep_2028_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
start_date="2028-01-29",
)
hits = _sweep_monthly(rule, 2028)
by_month = dict(hits)
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
months = [month for month, _ in hits]
assert months == list(range(1, 13))
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_interval_2_every_second_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-01-15",
)
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
"""Interval counts nominal months from the anchor: Jan 31 anchor,
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
start_date="2026-01-31",
)
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
start_date="2026-11-15",
)
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
def test_monthly_day_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
assert result == [
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
"2026-05-31", "2026-06-30",
]
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
start_date="2026-01-15",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-15", "2026-01-15")
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
"""The clamp must not double-fire: only one day per month matches."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-01-31",
)
hits = _sweep_monthly(rule, 2026)
months = [month for month, _ in hits]
assert months == sorted(months)
assert len(months) == len(set(months)) == 12
-324
View File
@@ -1,324 +0,0 @@
"""Tests for MONTHLY_WEEKDAY (nth-weekday-of-month) recurrence."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
_nth_weekday_of_month,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# The helper: nth occurrence day-of-month
# ---------------------------------------------------------------------------
def test_nth_weekday_helper_first_second_third() -> None:
"""Independently verified: Mar 1 2026 is a Sunday; Wednesdays are
4, 11, 18, 25 (four — no 5th)."""
assert _nth_weekday_of_month(2026, 3, 2, 1) == 4
assert _nth_weekday_of_month(2026, 3, 2, 2) == 11
assert _nth_weekday_of_month(2026, 3, 2, 3) == 18
assert _nth_weekday_of_month(2026, 3, 2, 4) == 25
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
"""Independently verified: April 2026 Fridays are 3, 10, 17, 24
(four) — no 5th occurrence."""
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
def test_nth_weekday_helper_last() -> None:
"""Independently verified: March 2026 Fridays are 6, 13, 20, 27;
February 2028 Saturdays are 5, 12, 19, 26 (Feb 29 2028 is a
Tuesday, so the last SATURDAY is the 26th, not the last day)."""
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
# February 2026: 28 days, last day IS a Saturday.
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
# February 2028: the last Saturday is the 26th (Feb 29 is a Tue).
assert _nth_weekday_of_month(2028, 2, 5, -1) == 26
def test_nth_weekday_helper_last_when_month_has_five() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the last is the 30th, and the 5th is the same day."""
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
assert _nth_weekday_of_month(2026, 10, 4, 5) == 30
def test_helper_zero_and_out_of_range_nth() -> None:
"""The model rejects n 0 before the helper sees it; -1/1..5 only."""
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=0,
nth_weekday_weekday=1, start_date="2026-09-01",
)
with pytest.raises(RuleValidationError):
ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=6,
nth_weekday_weekday=1, start_date="2026-09-01",
)
# ---------------------------------------------------------------------------
# occurs_on: first / second / third / fourth / last across 12 months
# ---------------------------------------------------------------------------
def test_first_tuesday_of_twelve_consecutive_months() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, # first Wednesday
start_date="2026-01-01",
)
first_wednesdays = [
(1, 7), (2, 4), (3, 4), (4, 1), (5, 6), (6, 3),
(7, 1), (8, 5), (9, 2), (10, 7), (11, 4), (12, 2),
]
for month, day in first_wednesdays:
date = datetime.date(2026, month, day)
assert date.weekday() == 2
assert occurs_on(rule, date) is True, date
# Neighbors do not fire.
assert occurs_on(rule, date - datetime.timedelta(days=7)) is False
assert occurs_on(rule, date + datetime.timedelta(days=7)) is False
def test_second_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified second Wednesdays of 2026 (Jan 1 is a
Thursday; Wednesdays per month from the calendar): the SECOND one
fires; the FIRST one does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
# (month, first_wed, second_wed) from plain datetime arithmetic:
verified = [
(1, 7, 14), (2, 4, 11), (3, 4, 11), (4, 1, 8),
(5, 6, 13), (6, 3, 10), (7, 1, 8), (8, 5, 12),
(9, 2, 9), (10, 7, 14), (11, 4, 11), (12, 2, 9),
]
for month, first_wed, second_wed in verified:
assert _d(f"2026-{month:02d}-{first_wed:02d}").weekday() == 2
assert _d(f"2026-{month:02d}-{second_wed:02d}").weekday() == 2
assert occurs_on(
rule, datetime.date(2026, month, second_wed)
) is True, f"2026-{month:02d}-{second_wed:02d}"
assert occurs_on(
rule, datetime.date(2026, month, first_wed)
) is False, f"2026-{month:02d}-{first_wed:02d}"
def test_third_friday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Fridays; the THIRD fires, the SECOND
does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
nth_weekday_weekday=4,
start_date="2026-01-01",
)
verified = [
# (month, first_fri, second_fri, third_fri)
(1, 2, 9, 16), (2, 6, 13, 20), (3, 6, 13, 20),
(4, 3, 10, 17), (5, 1, 8, 15), (6, 5, 12, 19),
(7, 3, 10, 17), (8, 7, 14, 21), (9, 4, 11, 18),
(10, 2, 9, 16), (11, 6, 13, 20), (12, 4, 11, 18),
]
for month, first_fri, second_fri, third_fri in verified:
third = _d(f"2026-{month:02d}-{third_fri:02d}")
assert third.weekday() == 4
assert occurs_on(rule, third) is True, third
second = _d(f"2026-{month:02d}-{second_fri:02d}")
assert occurs_on(rule, second) is False, second
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
"""Independently verified 2026 Wednesdays; the FOURTH fires, the
THIRD does not."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
nth_weekday_weekday=2,
start_date="2026-01-01",
)
verified = [
# (month, third_wed, fourth_wed)
(1, 21, 28), (2, 18, 25), (3, 18, 25), (4, 15, 22),
(5, 20, 27), (6, 17, 24), (7, 15, 22), (8, 19, 26),
(9, 16, 23), (10, 21, 28), (11, 18, 25), (12, 16, 23),
]
for month, third_wed, fourth_wed in verified:
fourth = _d(f"2026-{month:02d}-{fourth_wed:02d}")
assert fourth.weekday() == 2
assert occurs_on(rule, fourth) is True, fourth
third = _d(f"2026-{month:02d}-{third_wed:02d}")
assert not occurs_on(rule, third), third
def test_last_monday_of_twelve_consecutive_months() -> None:
"""Independently verified last Mondays of 2026; each fires, and the
PREVIOUS Monday of the same month does not (it is nth-from-last
minus one, i.e. the second-to-last)."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=0,
start_date="2026-01-01",
)
verified = [
# (month, last_monday)
(1, 26), (2, 23), (3, 30), (4, 27), (5, 25), (6, 29),
(7, 27), (8, 31), (9, 28), (10, 26), (11, 30), (12, 28),
]
for month, last_monday in verified:
date = _d(f"2026-{month:02d}-{last_monday:02d}")
assert date.weekday() == 0
assert occurs_on(rule, date) is True, date
# The Monday a week earlier is NOT the last: must not fire.
week_before = date - datetime.timedelta(days=7)
if week_before.month == month:
assert occurs_on(rule, week_before) is False, week_before
# ---------------------------------------------------------------------------
# The five-occurrence skip: a 5th on a four-occurrence month
# ---------------------------------------------------------------------------
def test_fifth_weekday_skips_four_occurrence_months() -> None:
"""Independently verified: 2026 months with FIVE Mondays are March,
June, August, November — every other month (four Mondays) must fire
on no day at all, and each hit must be the month's FINAL Monday."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=0, # fifth Monday
start_date="2026-01-01",
)
five_monday_months = {3: 30, 6: 29, 8: 31, 11: 30}
hit_months = set()
for month in range(1, 13):
if month in five_monday_months:
day = five_monday_months[month]
hit = _d(f"2026-{month:02d}-{day:02d}")
assert hit.weekday() == 0
assert occurs_on(rule, hit) is True, hit
# It is the FINAL Monday of the month: sweep EVERY remaining
# calendar day after the hit — none may fire.
for later in range(day + 1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, later)
) is False, f"2026-{month:02d}-{later:02d}"
# The Monday a week earlier is not the fifth.
assert occurs_on(
rule, datetime.date(2026, month, day - 7)
) is False
hit_months.add(month)
else:
for day in range(1, _month_end_days(2026, month) + 1):
assert occurs_on(
rule, datetime.date(2026, month, day)
) is False, f"2026-{month:02d}-{day:02d}"
assert hit_months == {3, 6, 8, 11}, hit_months
def _month_end_days(year: int, month: int) -> int:
if month == 12:
return 31
return (datetime.date(year, month + 1, 1)
- datetime.timedelta(days=1)).day
def test_fifth_weekday_fires_in_five_occurrence_months() -> None:
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
(five) — the 5th (and last) is the 30th; April 2026 has four Fridays
(3, 10, 17, 24) so no day in April fires."""
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
nth_weekday_weekday=4, # Fridays
start_date="2026-01-01",
)
# October: the 30th fires, is a Friday, and no Monday-later day
# (the 31st) is a Friday: the 30th is the FINAL Friday.
oct_30 = datetime.date(2026, 10, 30)
assert oct_30.weekday() == 4
assert occurs_on(rule, oct_30) is True
assert occurs_on(rule, datetime.date(2026, 10, 31)) is False
# April: no Friday-5th, so no day fires.
for day in range(1, _month_end_days(2026, 4) + 1):
assert occurs_on(rule, datetime.date(2026, 4, day)) is False, (
f"2026-04-{day:02d}"
)
# ---------------------------------------------------------------------------
# Interval arithmetic
# ---------------------------------------------------------------------------
def test_monthly_weekday_interval_2() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=2, interval=2, start_date="2026-01-01",
)
# Jan (m0) fires on the 2nd Tuesday; Feb (m1) skipped; Mar (m2) fires.
jan_second = _nth_weekday_of_month(2026, 1, 2, 2)
assert occurs_on(rule, datetime.date(2026, 1, jan_second)) is True
feb_second = _nth_weekday_of_month(2026, 2, 2, 2)
assert occurs_on(rule, datetime.date(2026, 2, feb_second)) is False
mar_second = _nth_weekday_of_month(2026, 3, 2, 2)
assert occurs_on(rule, datetime.date(2026, 3, mar_second)) is True
def test_monthly_weekday_interval_across_year_boundary() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=2, interval=3, start_date="2026-11-01",
)
# Nov (m0) fires; Dec (m1), Jan (m2) skip; Feb (m3) fires.
nov_first = _nth_weekday_of_month(2026, 11, 2, 1)
assert occurs_on(rule, datetime.date(2026, 11, nov_first)) is True
dec_first = _nth_weekday_of_month(2026, 12, 2, 1)
assert occurs_on(rule, datetime.date(2026, 12, dec_first)) is False
jan_first = _nth_weekday_of_month(2027, 1, 2, 1)
assert occurs_on(rule, datetime.date(2027, 1, jan_first)) is False
feb_first = _nth_weekday_of_month(2027, 2, 2, 1)
assert occurs_on(rule, datetime.date(2027, 2, feb_first)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_monthly_weekday_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=4, # second Friday
start_date="2026-01-01", end_date="2026-04-30",
)
result = occurrences_between(rule, "2026-01-01", "2026-04-30")
# Independently verified second Fridays: Jan 9 (Jan 1 Thu), Feb 13
# (Feb 1 Sun), Mar 13 (Mar 1 Sun), Apr 10 (Apr 1 Wed).
assert result == [
"2026-01-09", "2026-02-13", "2026-03-13", "2026-04-10",
]
def test_monthly_weekday_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
nth_weekday_weekday=1, start_date="2026-01-01",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2026-06-01", "2026-01-01")
-241
View File
@@ -1,241 +0,0 @@
"""Consistency property tests for occurrences_between vs occurs_on.
The done-condition: occurrences_between returns an ordered, duplicate-
free list of dates that agrees EXACTLY with calling occurs_on on every
date in the range. This module proves that agreement two ways:
1. An exhaustive sweep over a fixed grid of rules (every rule type ×
several intervals × several windows) cross-checked date by date.
2. A randomized property test (seeded, deterministic) over randomly
generated rules and ranges asserting the two functions never
disagree. Seeds are fixed so failures are reproducible.
"""
from __future__ import annotations
import datetime
import random
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
def _check_consistency(
rule: ScheduleRule, start: str, end: str
) -> list[str]:
"""Cross-check occurrences_between against per-date occurs_on.
Returns the occurrences_between result after asserting, for every
date in [start, end]: occurs_on(date) is True iff the date appears
in the result. Also asserts the list is ordered and duplicate-free.
"""
result = occurrences_between(rule, start, end)
# Ordered and duplicate-free.
assert result == sorted(set(result)), (
f"occurrences_between must be ordered, duplicate-free: {result}"
)
# Exact agreement with occurs_on over every date in the range.
cursor = _d(start)
end_date = _d(end)
expected: list[str] = []
while cursor <= end_date:
if occurs_on(rule, cursor):
expected.append(cursor.isoformat())
cursor += datetime.timedelta(days=1)
assert result == expected, (
f"disagreement for {rule.to_dict()} over [{start}, {end}]:\n"
f" occurrences_between: {result}\n"
f" per-date occurs_on: {expected}"
)
return result
# ---------------------------------------------------------------------------
# Exhaustive grid over rule shapes, intervals and windows
# ---------------------------------------------------------------------------
_GRID_RULES = [
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=2,
start_date="2026-03-01"),
ScheduleRule(rule_type=RuleType.DAILY, interval=3,
start_date="2026-03-15", end_date="2027-01-31"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
interval=2, start_date="2026-03-06",
end_date="2027-06-30"),
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 4},
start_date="2026-03-06"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
start_date="2026-03-31"),
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
interval=2, start_date="2026-01-29"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
nth_weekday_weekday=1, start_date="2026-03-03"),
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
nth_weekday_weekday=4, start_date="2026-03-06",
end_date="2028-12-31"),
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28"),
ScheduleRule(rule_type=RuleType.YEARLY, month=6, day_of_month=15,
interval=2, start_date="2026-06-15"),
]
_GRID_WINDOWS = [
("2026-01-01", "2026-03-31"), # mostly before start
("2026-03-01", "2026-03-01"), # single day == anchor
("2026-03-01", "2026-03-31"), # within window
("2026-08-01", "2026-08-31"), # mid-window month
("2026-12-01", "2027-01-31"), # year boundary
("2028-01-01", "2028-03-01"), # leap year span
("2029-01-01", "2029-12-31"), # far out
]
@pytest.mark.parametrize(
"rule_index,window_index",
[
(rule_index, window_index)
for rule_index in range(len(_GRID_RULES))
for window_index in range(len(_GRID_WINDOWS))
],
)
def test_grid_occurrences_between_matches_occurs_on(
rule_index, window_index
) -> None:
rule = _GRID_RULES[rule_index]
window = _GRID_WINDOWS[window_index]
_check_consistency(rule, window[0], window[1])
# ---------------------------------------------------------------------------
# Randomized property test (seeded, deterministic)
# ---------------------------------------------------------------------------
def _generate_random_rules(rng: random.Random, count: int) -> list[ScheduleRule]:
"""Random but valid rules: shapes, intervals, anchors, windows.
Any random combo the model rejects (genuinely invalid combinations)
is skipped — the property only needs VALID rules; the assertion at
the end bounds how many may be rejected.
"""
rules: list[ScheduleRule] = []
shapes = list(RuleType)
for index in range(count):
shape = shapes[rng.randrange(len(shapes))]
interval = rng.choice([1, 2, 3, 7])
anchor = datetime.date(
rng.choice([2024, 2025, 2026, 2027, 2028]),
rng.randrange(1, 13),
rng.randrange(1, 28),
)
end_offset = rng.choice([45, 120, 400, 800])
end = anchor + datetime.timedelta(days=end_offset)
common = {
"rule_type": shape,
"interval": interval,
"start_date": anchor.isoformat(),
"end_date": end.isoformat(),
}
try:
if shape is RuleType.WEEKLY:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.CUSTOM_DAYS:
rules.append(ScheduleRule(
**common,
weekday_set={
rng.randrange(7)
for _ in range(rng.randrange(1, 4))
},
))
elif shape is RuleType.MONTHLY_DAY:
rules.append(ScheduleRule(
**common, day_of_month=rng.randrange(1, 32)
))
elif shape is RuleType.MONTHLY_WEEKDAY:
rules.append(ScheduleRule(
**common,
nth_weekday=rng.choice([-1, 1, 2, 3, 4, 5]),
nth_weekday_weekday=rng.randrange(7),
))
elif shape is RuleType.YEARLY:
rules.append(ScheduleRule(
**common,
month=rng.randrange(1, 13),
day_of_month=rng.choice([None, rng.randrange(1, 29)]),
))
else: # DAILY
rules.append(ScheduleRule(**common))
except RuleValidationError:
continue
assert len(rules) >= count * 3 // 4, "too many random rules rejected"
return rules
def test_random_rules_never_disagree_between_the_two_functions() -> None:
"""The property: for randomly generated rules and ranges,
occurrences_between(rule, s, e) == [d for d in s..e if occurs_on]."""
rng = random.Random(0xFEA7) # fixed seed for reproducibility
rules = _generate_random_rules(rng, count=40)
checked = 0
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
for offset_days, span_days in ((-30, 60), (0, 14), (10, 90),
(100, 200)):
range_start = anchor + datetime.timedelta(days=offset_days)
range_end = range_start + datetime.timedelta(days=span_days)
_check_consistency(
rule, range_start.isoformat(), range_end.isoformat()
)
checked += 1
assert checked >= 40 * 4 * 3 // 4, checked
def test_random_ranges_include_year_and_leap_spans() -> None:
"""Every rule is probed across a range that spans Jan 1 (twice) and
a February 29 — the Dec 1..Mar 1 two-year span always covers both."""
rng = random.Random(42)
rules = _generate_random_rules(rng, count=12)
for rule in rules:
anchor = datetime.date.fromisoformat(rule.start_date)
probe_start = datetime.date(anchor.year, 12, 1)
probe_end = datetime.date(anchor.year + 2, 3, 1)
# The span always contains two Jan 1s and a Feb 28/29 pair.
assert probe_end >= probe_start
_check_consistency(
rule, probe_start.isoformat(), probe_end.isoformat()
)
# An additional probe pinned ON a Feb 29 window (2028 leap):
# every rule is cross-checked over Feb 1 - Mar 15 2028.
_check_consistency(rule, "2028-02-01", "2028-03-15")
def test_occurrences_between_single_day_range_matches_occurs_on() -> None:
"""A one-day range is the exact occurs_on of that date."""
for rule in _GRID_RULES:
for day in ("2026-03-05", "2026-03-15", "2027-06-15"):
single = occurrences_between(rule, day, day)
if occurs_on(rule, _d(day)):
assert single == [day], (rule.to_dict(), day, single)
else:
assert single == [], (rule.to_dict(), day, single)
-299
View File
@@ -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
-233
View File
@@ -1,233 +0,0 @@
"""Tests for YEARLY recurrence with the leap-day clamp policy."""
from __future__ import annotations
import datetime
import pytest
from custom_components.nestquest.recurrence import (
RuleType,
RuleValidationError,
ScheduleRule,
occurs_on,
occurrences_between,
)
def _d(date: str) -> datetime.date:
return datetime.date.fromisoformat(date)
# ---------------------------------------------------------------------------
# Nominal-day firings across four consecutive years (incl. leap 2028)
# ---------------------------------------------------------------------------
def test_yearly_june_15_four_consecutive_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 6, 15)) is True, year
assert occurs_on(rule, datetime.date(2026, 6, 14)) is False
assert occurs_on(rule, datetime.date(2026, 6, 16)) is False
assert occurs_on(rule, datetime.date(2026, 5, 15)) is False
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
def test_yearly_feb_15_across_four_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=15,
start_date="2026-02-15",
)
# Feb 15 exists every year; the leap day is irrelevant.
for year in (2026, 2027, 2028, 2029):
assert occurs_on(rule, datetime.date(year, 2, 15)) is True, year
def test_yearly_never_fires_before_start() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2027-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is False
assert occurs_on(rule, datetime.date(2027, 6, 15)) is True
def test_yearly_respects_end_date() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15", end_date="2028-06-15",
)
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False
# ---------------------------------------------------------------------------
# Leap-day policy: Feb 29 rule fires Feb 28 in non-leap years
# ---------------------------------------------------------------------------
def test_yearly_feb_29_clamps_to_feb_28_non_leap() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2026 non-leap: no Feb 29 — the clamp fires Feb 28.
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
# 2027 non-leap: same clamp.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
def test_yearly_feb_29_fires_feb_29_in_leap_years() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
# 2028 leap: Feb 29 exists — nominal day.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
def test_yearly_feb_29_four_consecutive_years() -> None:
"""The done-condition's four-year sweep: 2026 (clamp 28), 2027
(clamp 28), 2028 (leap: nominal 29), 2029 (clamp 28)."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2029, 2, 28)) is True
# No other day in those Februaries fires.
for year, non_firing in (
(2026, (27,)), (2027, (26, 27)), (2028, (27,)),
(2029, (26, 27)),
):
for day in non_firing:
assert occurs_on(rule, datetime.date(year, 2, day)) is False
def test_yearly_feb_30_is_impossible_by_model() -> None:
"""The model rejects day_of_month 30+ for February? No — 30/31 are
accepted (1..31) and clamp to the month end like MONTHLY_DAY."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=30,
start_date="2027-02-28",
)
# Non-leap 2027: Feb 28 is the month end — the clamp fires it.
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
# Leap 2028: Feb 29 is the month end — clamps there.
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
# ---------------------------------------------------------------------------
# Day-less YEARLY rules fire on the first of the month
# ---------------------------------------------------------------------------
def test_yearly_without_day_fires_on_first_of_month() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, start_date="2026-01-01",
)
assert occurs_on(rule, datetime.date(2026, 6, 1)) is True
assert occurs_on(rule, datetime.date(2026, 6, 2)) is False
assert occurs_on(rule, datetime.date(2026, 5, 1)) is False
assert occurs_on(rule, datetime.date(2027, 6, 1)) is True
# ---------------------------------------------------------------------------
# Interval: every N years
# ---------------------------------------------------------------------------
def test_yearly_interval_2_every_second_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
start_date="2026-06-15",
)
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True # y0
assert occurs_on(rule, datetime.date(2027, 6, 15)) is False # y1
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True # y2
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False # y3
assert occurs_on(rule, datetime.date(2030, 6, 15)) is True # y4
def test_yearly_interval_2_leap_day_every_second_year() -> None:
"""A Feb 29 rule at interval 2 anchored 2026 fires in even years:
2026 clamps (Feb 28), 2027 skips, 2028 hits the nominal Feb 29."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29, interval=2,
start_date="2026-02-28",
)
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # y0 clamp
assert occurs_on(rule, datetime.date(2027, 2, 28)) is False # y1
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True # y2 nominal
assert occurs_on(rule, datetime.date(2029, 2, 28)) is False # y3
assert occurs_on(rule, datetime.date(2030, 2, 28)) is True # y4 clamp
def test_yearly_interval_3_across_start_year() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=3, day_of_month=10, interval=3,
start_date="2026-03-10",
)
assert occurs_on(rule, datetime.date(2026, 3, 10)) is True
assert occurs_on(rule, datetime.date(2027, 3, 10)) is False
assert occurs_on(rule, datetime.date(2028, 3, 10)) is False
assert occurs_on(rule, datetime.date(2029, 3, 10)) is True
# ---------------------------------------------------------------------------
# occurrences_between
# ---------------------------------------------------------------------------
def test_yearly_occurrences_between_span() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
result = occurrences_between(rule, "2026-01-01", "2029-12-31")
assert result == [
"2026-02-28", "2027-02-28", "2028-02-29", "2029-02-28",
]
def test_yearly_occurrences_between_disjoint_range() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
start_date="2026-06-15",
)
# The rule only ever fires in June: other months are empty.
assert occurrences_between(rule, "2027-01-01", "2027-05-31") == []
assert occurrences_between(rule, "2027-07-01", "2027-12-31") == []
def test_yearly_occurrences_between_rejects_inverted() -> None:
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
start_date="2026-02-28",
)
with pytest.raises(RuleValidationError, match="on or after"):
occurrences_between(rule, "2029-01-01", "2026-01-01")
# ---------------------------------------------------------------------------
# Day of month 31 in a 30-day month: clamp consistency with MONTHLY_DAY
# ---------------------------------------------------------------------------
def test_yearly_june_31_clamps_to_june_30() -> None:
"""June has 30 days: a June 31st yearly rule clamps to June 30."""
rule = ScheduleRule(
rule_type=RuleType.YEARLY, month=6, day_of_month=31,
start_date="2026-06-30",
)
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True
assert occurs_on(rule, datetime.date(2027, 6, 30)) is True
assert occurs_on(rule, datetime.date(2026, 6, 29)) is False
+4 -1
View File
@@ -100,11 +100,14 @@ def test_executor_helper_creates_directory(tmp_path) -> None:
assert nested.is_dir()
def test_no_sqlite3_import_in_package() -> None:
def test_no_sqlite3_import_outside_db_module() -> None:
"""sqlite3 is banned package-wide except db.py, the connection wrapper."""
pkg_dir = Path(store.__file__).parent
py_files = list(pkg_dir.glob("*.py"))
assert py_files
for py_file in py_files:
if py_file.name == "db.py":
continue
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Import):