Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4163667b07 | ||
|
|
deb409175a | ||
|
|
6398bd525a | ||
|
|
9fd06f40bb | ||
|
|
d006d0606d |
@@ -4,7 +4,7 @@ Home Assistant custom integration for managing chores, custody schedules, and ho
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. Version 0.1.0 is the integration foundation: a configuration scaffold providing the package and manifest, shared constants, a single-instance config flow, setup/unload lifecycle handling with an options-reload listener, an options flow (horizon days, day rollover time, panel idle timeout), SQLite storage-path resolution, and HACS metadata. Sensors, calendar events, and service calls are forthcoming in later releases and are not yet part of the integration.
|
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. It provides sensors, calendar events, and service calls designed for busy households and modern co-parenting setups.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# NestQuest Release Notes
|
|
||||||
|
|
||||||
## Version 0.1.0 — 2026-09-13
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
First release of NestQuest, a Home Assistant custom integration for managing chores, custody schedules, and house coordination. This release ships the integration foundation/configuration scaffold only (Feature 01):
|
|
||||||
|
|
||||||
- Integration package and manifest, plus HACS metadata (`hacs.json`).
|
|
||||||
- Shared constants module.
|
|
||||||
- Single-instance config flow for initial setup.
|
|
||||||
- Setup/unload lifecycle handling with an options-reload listener.
|
|
||||||
- Options flow for configuring `horizon_days`, `day_rollover_time`, and `panel_idle_timeout`.
|
|
||||||
- SQLite storage path resolver — path resolution only; the database itself is not created in this release.
|
|
||||||
- Test harness (`pytest` via `uv`).
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- Minimum Home Assistant version: **2024.6.0**.
|
|
||||||
|
|
||||||
### Known Limitations
|
|
||||||
|
|
||||||
- No entities, services, or calendar platforms yet — the integration does not expose sensors, calendar events, or service calls in this release.
|
|
||||||
- Single-instance only — one NestQuest config entry is permitted per Home Assistant instance.
|
|
||||||
- The integration does not yet create its database file; storage is limited to resolved configuration paths.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
v0.1.0 is the first release — there is no previous NestQuest version to downgrade to, so rollback means complete removal. This release performs no data or schema migrations.
|
|
||||||
|
|
||||||
**HACS installs:**
|
|
||||||
|
|
||||||
1. Remove the NestQuest integration via HACS (HACS > Integrations > NestQuest > Remove).
|
|
||||||
2. Restart Home Assistant.
|
|
||||||
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
|
||||||
|
|
||||||
**Manual installs:**
|
|
||||||
|
|
||||||
1. Delete the `custom_components/nestquest/` directory from your Home Assistant configuration directory.
|
|
||||||
2. Restart Home Assistant.
|
|
||||||
3. Remove the integration in Home Assistant (Settings > Devices & Services > NestQuest > Delete), then restart Home Assistant again.
|
|
||||||
|
|
||||||
**Data:** the integration does not yet create its own database file, so there is no application data. However, Home Assistant persists the config entry and its selected options — removing the integration deletes that configuration, and the option values (`horizon_days`, `day_rollover_time`, `panel_idle_timeout`) will be lost on removal.
|
|
||||||
|
|
||||||
**Repository operators:** instead of uninstalling, you may git-revert the release merge on `main`, which removes the integration files entirely. End users should use the uninstall path above.
|
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
"name": "NestQuest",
|
"name": "NestQuest",
|
||||||
"codeowners": ["@overseer"],
|
"codeowners": ["@overseer"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://github.com/talon2king/NestQuest",
|
"documentation": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/talon2king/NestQuest/issues",
|
"issue_tracker": "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||||
|
|
||||||
|
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||||
|
frozen dataclass with no Home Assistant and no database imports. The
|
||||||
|
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
||||||
|
serializes its dict form into the ``schedule_rules`` table.
|
||||||
|
|
||||||
|
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
|
||||||
|
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
|
||||||
|
Tuesday of every month") are distinct types rather than one monthly
|
||||||
|
type with optional fields, so validation is total: a rule either
|
||||||
|
constructs, or raises :class:`RuleValidationError` naming the offending
|
||||||
|
field. Every type carries ONLY the fields its shape needs; carrying
|
||||||
|
any other shape's field is a construction error.
|
||||||
|
|
||||||
|
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
|
||||||
|
``storage_value`` maps model types onto the schema's CHECK values —
|
||||||
|
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
|
||||||
|
disambiguated by which fields are populated; CUSTOM_DAYS stores as
|
||||||
|
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
|
||||||
|
table stores (one element for MONTHLY_WEEKDAY, which names the
|
||||||
|
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
|
||||||
|
carried through from_dict/to_dict for losslessness and folded into
|
||||||
|
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
|
||||||
|
model-side lossless pair; the DB mapping lives in the DAO layer and is
|
||||||
|
exercised there.
|
||||||
|
|
||||||
|
Validation policy (fail fast at construction — the schema's CHECK
|
||||||
|
constraints are a second line of defense, but invalid rules must never
|
||||||
|
reach storage):
|
||||||
|
|
||||||
|
- ``interval`` >= 1 for every rule.
|
||||||
|
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||||
|
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
|
||||||
|
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||||
|
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
|
||||||
|
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
|
||||||
|
- ``month`` 1..12 is required for YEARLY.
|
||||||
|
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||||
|
when present, on or after ``start_date``.
|
||||||
|
|
||||||
|
Month-end policy (documented for Feature 04): a MONTHLY_DAY rule for
|
||||||
|
the 31st does NOT fire in a 30-day month, and a YEARLY rule for
|
||||||
|
February 29th does NOT fire in a non-leap year — the engine skips
|
||||||
|
instead of clamping to month end, because clamping silently moves
|
||||||
|
"the 31st" to "the 30th" and double-fires tasks.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import enum
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||||
|
_DATE_FORMAT = "%Y-%m-%d"
|
||||||
|
|
||||||
|
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
|
||||||
|
|
||||||
|
|
||||||
|
class RuleType(enum.Enum):
|
||||||
|
"""The six recurrence shapes the engine supports.
|
||||||
|
|
||||||
|
``storage_value`` is the string written to the ``schedule_rules``
|
||||||
|
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
|
||||||
|
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
|
||||||
|
disambiguated by which fields are populated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DAILY = "daily"
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
MONTHLY_DAY = "monthly_day"
|
||||||
|
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||||
|
YEARLY = "yearly"
|
||||||
|
CUSTOM_DAYS = "custom_days"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def storage_value(self) -> str:
|
||||||
|
"""The schema-level rule_type string this model type maps to."""
|
||||||
|
return {
|
||||||
|
RuleType.DAILY: "daily",
|
||||||
|
RuleType.WEEKLY: "weekly",
|
||||||
|
RuleType.MONTHLY_DAY: "monthly",
|
||||||
|
RuleType.MONTHLY_WEEKDAY: "monthly",
|
||||||
|
RuleType.YEARLY: "yearly",
|
||||||
|
RuleType.CUSTOM_DAYS: "custom",
|
||||||
|
}[self]
|
||||||
|
|
||||||
|
|
||||||
|
class RuleValidationError(ValueError):
|
||||||
|
"""Raised when a ScheduleRule field combination is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_date(value: str, field_name: str) -> None:
|
||||||
|
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||||
|
try:
|
||||||
|
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||||
|
) from None
|
||||||
|
if parsed.isoformat() != value:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weekday_set(weekday_set) -> frozenset[int]:
|
||||||
|
"""Return a normalized frozenset or raise with a clear message.
|
||||||
|
|
||||||
|
Sequence entries are validated BEFORE set() coercion: a list like
|
||||||
|
[0, False] must not collapse the boolean into 0, and unhashable
|
||||||
|
entries must raise RuleValidationError, never a raw TypeError.
|
||||||
|
"""
|
||||||
|
if isinstance(weekday_set, (list, tuple)):
|
||||||
|
for entry in weekday_set:
|
||||||
|
# _is_plain_int: bool and int subclasses (potentially
|
||||||
|
# unhashable) are rejected before set() coercion.
|
||||||
|
if not _is_plain_int(entry):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set entries must be plain integers, got "
|
||||||
|
f"{entry!r}"
|
||||||
|
)
|
||||||
|
weekday_set = set(weekday_set)
|
||||||
|
if not isinstance(weekday_set, (set, frozenset)):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set must be a set of weekday integers, got "
|
||||||
|
f"{type(weekday_set).__name__}"
|
||||||
|
)
|
||||||
|
if not weekday_set:
|
||||||
|
raise RuleValidationError(
|
||||||
|
"weekday_set must not be empty for weekly/custom rules"
|
||||||
|
)
|
||||||
|
for entry in weekday_set:
|
||||||
|
if not _is_plain_int(entry):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set entries must be plain integers, got "
|
||||||
|
f"{entry!r}"
|
||||||
|
)
|
||||||
|
if not 0 <= entry <= 6:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||||
|
f"got {entry!r}"
|
||||||
|
)
|
||||||
|
return frozenset(weekday_set)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_plain_int(value) -> bool:
|
||||||
|
"""True only for real ints: bool excluded AND int subclasses too
|
||||||
|
(an unhashable int subclass would break the frozen-hashable model)."""
|
||||||
|
return type(value) is int
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
|
||||||
|
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||||
|
if nth_weekday is None or not _is_plain_int(nth_weekday):
|
||||||
|
raise RuleValidationError(
|
||||||
|
"nth_weekday must be an integer for monthly_weekday rules, "
|
||||||
|
f"got {nth_weekday!r}"
|
||||||
|
)
|
||||||
|
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||||
|
)
|
||||||
|
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
|
||||||
|
f"(Sunday), got {weekday!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScheduleRule:
|
||||||
|
"""An immutable, validated recurrence rule (see module docstring).
|
||||||
|
|
||||||
|
Construct with keyword arguments; invalid combinations raise
|
||||||
|
:class:`RuleValidationError` at construction time. Fields map
|
||||||
|
one-to-one onto the ``schedule_rules`` table columns, with
|
||||||
|
``nth_weekday_weekday`` as the model-only weekday companion to
|
||||||
|
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
|
||||||
|
boundary).
|
||||||
|
"""
|
||||||
|
|
||||||
|
rule_type: RuleType
|
||||||
|
interval: int = 1
|
||||||
|
weekday_set: frozenset[int] | None = None
|
||||||
|
day_of_month: int | None = None
|
||||||
|
nth_weekday: int | None = None
|
||||||
|
nth_weekday_weekday: int | None = None
|
||||||
|
month: int | None = None
|
||||||
|
start_date: str = "1970-01-01"
|
||||||
|
end_date: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# Normalize a string rule_type into the enum; anything else
|
||||||
|
# that is not already a RuleType is rejected.
|
||||||
|
if isinstance(self.rule_type, str):
|
||||||
|
# EXACT model names only: no whitespace stripping, no storage
|
||||||
|
# aliases. The DAO maps storage strings to model fields when
|
||||||
|
# reading rows; the model boundary stays unambiguous.
|
||||||
|
candidates = {rt.name.lower(): rt for rt in RuleType}
|
||||||
|
if self.rule_type in candidates:
|
||||||
|
object.__setattr__(
|
||||||
|
self, "rule_type", candidates[self.rule_type]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"rule_type must be a RuleType or one of "
|
||||||
|
f"{sorted(candidates)}, got {self.rule_type!r}"
|
||||||
|
)
|
||||||
|
if not isinstance(self.rule_type, RuleType):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"rule_type must be a RuleType or one of its string "
|
||||||
|
f"values, got {self.rule_type!r}"
|
||||||
|
)
|
||||||
|
if not _is_plain_int(self.interval):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"interval must be an integer >= 1, got {self.interval!r}"
|
||||||
|
)
|
||||||
|
if self.interval < 1:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"interval must be >= 1, got {self.interval!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_date(self.start_date, "start_date")
|
||||||
|
if self.end_date is not None:
|
||||||
|
_validate_date(self.end_date, "end_date")
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"end_date {self.end_date!r} must be on or after "
|
||||||
|
f"start_date {self.start_date!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type-exclusive field policy, enforced for every type: build
|
||||||
|
# the dict of present fields, then reject every field the shape
|
||||||
|
# must not carry (weekday_set present is detectable via None vs
|
||||||
|
# set even after normalization below).
|
||||||
|
shape = self.rule_type
|
||||||
|
if shape is RuleType.DAILY:
|
||||||
|
forbidden_fields = (
|
||||||
|
"weekday_set",
|
||||||
|
"day_of_month",
|
||||||
|
"nth_weekday",
|
||||||
|
"nth_weekday_weekday",
|
||||||
|
"month",
|
||||||
|
)
|
||||||
|
elif shape is RuleType.WEEKLY:
|
||||||
|
forbidden_fields = (
|
||||||
|
"day_of_month",
|
||||||
|
"nth_weekday",
|
||||||
|
"nth_weekday_weekday",
|
||||||
|
"month",
|
||||||
|
)
|
||||||
|
elif shape is RuleType.CUSTOM_DAYS:
|
||||||
|
forbidden_fields = (
|
||||||
|
"day_of_month",
|
||||||
|
"nth_weekday",
|
||||||
|
"nth_weekday_weekday",
|
||||||
|
"month",
|
||||||
|
)
|
||||||
|
elif shape is RuleType.MONTHLY_DAY:
|
||||||
|
forbidden_fields = ("weekday_set", "nth_weekday",
|
||||||
|
"nth_weekday_weekday", "month")
|
||||||
|
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||||
|
forbidden_fields = ("weekday_set", "day_of_month", "month")
|
||||||
|
else: # YEARLY
|
||||||
|
forbidden_fields = (
|
||||||
|
"weekday_set",
|
||||||
|
"day_of_month",
|
||||||
|
"nth_weekday",
|
||||||
|
"nth_weekday_weekday",
|
||||||
|
)
|
||||||
|
present_forbidden = [
|
||||||
|
name
|
||||||
|
for name in forbidden_fields
|
||||||
|
if getattr(self, name) is not None
|
||||||
|
]
|
||||||
|
if present_forbidden:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"{shape.value} rules must not set "
|
||||||
|
f"{', '.join(sorted(present_forbidden))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Required fields per shape.
|
||||||
|
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||||
|
normalized = _validate_weekday_set(self.weekday_set)
|
||||||
|
object.__setattr__(self, "weekday_set", normalized)
|
||||||
|
elif shape is RuleType.MONTHLY_DAY:
|
||||||
|
if self.day_of_month is None or not _is_plain_int(
|
||||||
|
self.day_of_month
|
||||||
|
):
|
||||||
|
raise RuleValidationError(
|
||||||
|
"monthly_day rules require day_of_month (1..31)"
|
||||||
|
)
|
||||||
|
if not 1 <= self.day_of_month <= 31:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"day_of_month must be 1..31, got {self.day_of_month!r}"
|
||||||
|
)
|
||||||
|
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||||
|
_validate_monthly_weekday(
|
||||||
|
self.nth_weekday, self.nth_weekday_weekday
|
||||||
|
)
|
||||||
|
elif shape is RuleType.YEARLY:
|
||||||
|
if self.month is None or not _is_plain_int(self.month):
|
||||||
|
raise RuleValidationError(
|
||||||
|
"yearly rules require month (1..12)"
|
||||||
|
)
|
||||||
|
if not 1 <= self.month <= 12:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"month must be 1..12, got {self.month!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Return the lossless dict form (model-side storage shape).
|
||||||
|
|
||||||
|
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
|
||||||
|
weekday travels inside it (appended) plus
|
||||||
|
``nth_weekday_weekday`` so the pair round-trips without
|
||||||
|
ambiguity at the model layer.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"rule_type": self.rule_type.name.lower(), # model name
|
||||||
|
"interval": self.interval,
|
||||||
|
"weekday_set": (
|
||||||
|
sorted(self.weekday_set) if self.weekday_set is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"day_of_month": self.day_of_month,
|
||||||
|
"nth_weekday": self.nth_weekday,
|
||||||
|
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||||
|
"month": self.month,
|
||||||
|
"start_date": self.start_date,
|
||||||
|
"end_date": self.end_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||||
|
"""Rebuild a rule from :meth:`to_dict` output.
|
||||||
|
|
||||||
|
Raises :class:`RuleValidationError` on unknown keys, missing
|
||||||
|
keys, or invalid combinations — never a raw TypeError.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"rule data must be a dict, got {type(data).__name__}"
|
||||||
|
)
|
||||||
|
allowed = {
|
||||||
|
"rule_type",
|
||||||
|
"interval",
|
||||||
|
"weekday_set",
|
||||||
|
"day_of_month",
|
||||||
|
"nth_weekday",
|
||||||
|
"nth_weekday_weekday",
|
||||||
|
"month",
|
||||||
|
"start_date",
|
||||||
|
"end_date",
|
||||||
|
}
|
||||||
|
unknown = [key for key in data if key not in allowed]
|
||||||
|
if unknown:
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"unknown rule fields: {sorted(unknown, key=repr)}"
|
||||||
|
)
|
||||||
|
if "rule_type" not in data:
|
||||||
|
raise RuleValidationError("missing required rule field: rule_type")
|
||||||
|
weekday_set = data.get("weekday_set")
|
||||||
|
if isinstance(weekday_set, (list, tuple)):
|
||||||
|
# Validate entries as plain ints BEFORE set() coercion, so a
|
||||||
|
# list like [0, False] cannot collapse False into 0 and an
|
||||||
|
# unhashable/int-subclass entry cannot leak a raw TypeError.
|
||||||
|
for entry in weekday_set:
|
||||||
|
if not _is_plain_int(entry):
|
||||||
|
raise RuleValidationError(
|
||||||
|
f"weekday_set entries must be plain integers, got "
|
||||||
|
f"{entry!r}"
|
||||||
|
)
|
||||||
|
weekday_set = set(weekday_set)
|
||||||
|
return cls(
|
||||||
|
rule_type=data["rule_type"],
|
||||||
|
interval=data.get("interval", 1),
|
||||||
|
weekday_set=weekday_set,
|
||||||
|
day_of_month=data.get("day_of_month"),
|
||||||
|
nth_weekday=data.get("nth_weekday"),
|
||||||
|
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||||
|
month=data.get("month"),
|
||||||
|
start_date=data.get("start_date", "1970-01-01"),
|
||||||
|
end_date=data.get("end_date"),
|
||||||
|
)
|
||||||
@@ -56,24 +56,6 @@ def test_manifest_requirements(manifest: dict) -> None:
|
|||||||
assert manifest.get("requirements") == []
|
assert manifest.get("requirements") == []
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_documentation_url_is_public_github_mirror(manifest: dict) -> None:
|
|
||||||
"""Verify documentation URL points at the public GitHub mirror."""
|
|
||||||
assert manifest.get("documentation") == "https://github.com/talon2king/NestQuest"
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_issue_tracker_url_is_public_github_mirror(manifest: dict) -> None:
|
|
||||||
"""Verify issue_tracker URL points at the public GitHub mirror."""
|
|
||||||
assert manifest.get("issue_tracker") == "https://github.com/talon2king/NestQuest/issues"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("key", ["documentation", "issue_tracker"])
|
|
||||||
def test_manifest_urls_use_github_com(manifest: dict, key: str) -> None:
|
|
||||||
"""Verify manifest URLs use github.com rather than the private Gitea host."""
|
|
||||||
url = manifest.get(key, "")
|
|
||||||
assert url.startswith("https://github.com/talon2king/NestQuest")
|
|
||||||
assert "cubecraftlabs" not in url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"key",
|
"key",
|
||||||
["name", "version", "documentation", "issue_tracker", "codeowners"],
|
["name", "version", "documentation", "issue_tracker", "codeowners"],
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
"""Tests for recurrence.py: the ScheduleRule model and serialization."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from custom_components.nestquest.recurrence import (
|
||||||
|
RuleType,
|
||||||
|
RuleValidationError,
|
||||||
|
ScheduleRule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule(**kwargs) -> ScheduleRule:
|
||||||
|
return ScheduleRule(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Valid construction for every rule type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_rule_constructs_with_defaults() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||||
|
assert rule.rule_type is RuleType.DAILY
|
||||||
|
assert rule.interval == 1
|
||||||
|
assert rule.weekday_set is None
|
||||||
|
assert rule.day_of_month is None
|
||||||
|
assert rule.nth_weekday is None
|
||||||
|
assert rule.month is None
|
||||||
|
assert rule.end_date is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_rule_type_coerces() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
||||||
|
assert rule.rule_type is RuleType.WEEKLY
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_rule_requires_and_freezes_weekday_set() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01")
|
||||||
|
assert rule.weekday_set == frozenset({0, 2, 4})
|
||||||
|
# A mutable set passed in is frozen on construction.
|
||||||
|
source = {1, 3}
|
||||||
|
rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01")
|
||||||
|
source.add(5)
|
||||||
|
assert rule2.weekday_set == frozenset({1, 3})
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_day_rule_fields() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01")
|
||||||
|
assert rule.day_of_month == 15
|
||||||
|
assert rule.nth_weekday is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_weekday_rule_fields() -> None:
|
||||||
|
rule = _rule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||||
|
nth_weekday=2,
|
||||||
|
nth_weekday_weekday=1,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
assert rule.nth_weekday == 2
|
||||||
|
assert rule.nth_weekday_weekday == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_weekday_last_friday_allowed() -> None:
|
||||||
|
rule = _rule(
|
||||||
|
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||||
|
nth_weekday=-1,
|
||||||
|
nth_weekday_weekday=4,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
assert rule.nth_weekday == -1
|
||||||
|
|
||||||
|
|
||||||
|
def test_yearly_rule_fields() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01")
|
||||||
|
assert rule.month == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_days_rule_fields() -> None:
|
||||||
|
rule = _rule(
|
||||||
|
rule_type=RuleType.CUSTOM_DAYS,
|
||||||
|
weekday_set={1, 3, 5},
|
||||||
|
interval=2,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
assert rule.weekday_set == frozenset({1, 3, 5})
|
||||||
|
assert rule.interval == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_date_round_trips(tmp_path) -> None:
|
||||||
|
rule = _rule(
|
||||||
|
rule_type=RuleType.DAILY,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
end_date="2026-12-31",
|
||||||
|
)
|
||||||
|
assert rule.end_date == "2026-12-31"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Invalid combinations are rejected at construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs",
|
||||||
|
[
|
||||||
|
# weekly without weekday_set
|
||||||
|
{"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"},
|
||||||
|
# daily carrying a weekday_set
|
||||||
|
{"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"},
|
||||||
|
# monthly_day without day_of_month
|
||||||
|
{"rule_type": "monthly_day", "start_date": "2026-09-01"},
|
||||||
|
# monthly_day out of range
|
||||||
|
{"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"},
|
||||||
|
# monthly_day carrying weekday_set
|
||||||
|
{"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0},
|
||||||
|
"start_date": "2026-09-01"},
|
||||||
|
# yearly without month
|
||||||
|
{"rule_type": "yearly", "start_date": "2026-09-01"},
|
||||||
|
# yearly out of range
|
||||||
|
{"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"},
|
||||||
|
# unknown type
|
||||||
|
{"rule_type": "bogus", "start_date": "2026-09-01"},
|
||||||
|
# interval zero
|
||||||
|
{"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"},
|
||||||
|
# interval non-integer
|
||||||
|
{"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"},
|
||||||
|
# end before start
|
||||||
|
{"rule_type": "daily", "start_date": "2026-09-01",
|
||||||
|
"end_date": "2026-08-31"},
|
||||||
|
# malformed dates
|
||||||
|
{"rule_type": "daily", "start_date": "not-a-date"},
|
||||||
|
{"rule_type": "daily", "start_date": "2026-9-1"},
|
||||||
|
# weekly weekday out of range
|
||||||
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"},
|
||||||
|
# weekly empty set
|
||||||
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"},
|
||||||
|
# custom_days carrying month-specific fields
|
||||||
|
{"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5,
|
||||||
|
"start_date": "2026-09-01"},
|
||||||
|
{"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1,
|
||||||
|
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
||||||
|
{"rule_type": "custom_days", "weekday_set": {0}, "month": 3,
|
||||||
|
"start_date": "2026-09-01"},
|
||||||
|
# monthly_weekday position invalid
|
||||||
|
{"rule_type": "monthly_weekday", "nth_weekday": 0,
|
||||||
|
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
||||||
|
{"rule_type": "monthly_weekday", "nth_weekday": 6,
|
||||||
|
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
||||||
|
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
||||||
|
"nth_weekday_weekday": None, "start_date": "2026-09-01"},
|
||||||
|
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
||||||
|
"nth_weekday_weekday": 7, "start_date": "2026-09-01"},
|
||||||
|
# monthly_day carrying nth fields
|
||||||
|
{"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1,
|
||||||
|
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_combinations_raise(kwargs) -> None:
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
_rule(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Immutability
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_is_immutable() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
rule.interval = 2
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
del rule.interval
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialization round-trip for every rule type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"rule",
|
||||||
|
[
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01",
|
||||||
|
end_date="2026-12-31"),
|
||||||
|
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||||
|
start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2,
|
||||||
|
start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||||
|
start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3,
|
||||||
|
start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||||
|
nth_weekday_weekday=1, start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||||
|
nth_weekday_weekday=4, interval=2,
|
||||||
|
start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None,
|
||||||
|
interval=2, start_date="2026-09-01"),
|
||||||
|
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
|
||||||
|
interval=2, start_date="2026-09-01"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_to_dict_from_dict_round_trips_losslessly(rule) -> None:
|
||||||
|
rebuilt = ScheduleRule.from_dict(rule.to_dict())
|
||||||
|
assert rebuilt == rule
|
||||||
|
# Round-tripping twice is stable.
|
||||||
|
assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_rejects_unknown_fields() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
||||||
|
ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_rejects_missing_rule_type() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="missing required"):
|
||||||
|
ScheduleRule.from_dict({"interval": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_rejects_non_dict() -> None:
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
ScheduleRule.from_dict("daily")
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_defaults_start_date_when_absent() -> None:
|
||||||
|
rule = ScheduleRule.from_dict({"rule_type": "daily"})
|
||||||
|
assert rule.start_date == "1970-01-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_accepts_list_weekday_set() -> None:
|
||||||
|
rule = ScheduleRule.from_dict(
|
||||||
|
{"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]}
|
||||||
|
)
|
||||||
|
assert rule.weekday_set == frozenset({0, 2, 4})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Equality and hashing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_rules_compare_equal_and_hash_equal() -> None:
|
||||||
|
a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
||||||
|
b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01")
|
||||||
|
assert a == b
|
||||||
|
assert hash(a) == hash(b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_rules_compare_unequal() -> None:
|
||||||
|
a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||||
|
b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_not_equal_to_other_types() -> None:
|
||||||
|
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||||
|
assert rule != "daily"
|
||||||
|
assert (rule == 42) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Purity guards: no HA imports, no database access
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_recurrence_module_imports_only_stdlib() -> None:
|
||||||
|
"""The model must carry no Home Assistant and no DB imports."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
source = Path(
|
||||||
|
__import__("custom_components.nestquest.recurrence",
|
||||||
|
fromlist=["__file__"]).__file__
|
||||||
|
).read_text()
|
||||||
|
offenders = _scan_forbidden_imports(source)
|
||||||
|
assert offenders == [], (
|
||||||
|
f"recurrence.py must not hold forbidden imports: {offenders}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_forbidden_imports(source: str) -> list[str]:
|
||||||
|
"""The ACTUAL purity-guard logic, shared by the model scan and the
|
||||||
|
probe tests below: returns human-readable offenders (empty = clean).
|
||||||
|
|
||||||
|
Forbidden, at any relative level, for both Import and ImportFrom:
|
||||||
|
- modules containing 'homeassistant' or 'sqlite'
|
||||||
|
- modules whose LEAF name is a DB/store/schema/DAO module
|
||||||
|
- aliases importing a DB/store/schema/DAO module by leaf name
|
||||||
|
('from . import db', 'from custom_components.nestquest import db')
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
forbidden_targets = {
|
||||||
|
"db", "schema", "dao_children", "dao_rules", "dao_presence",
|
||||||
|
"dao_instances", "migrations", "store",
|
||||||
|
}
|
||||||
|
offenders: list[str] = []
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
lowered = alias.name.lower()
|
||||||
|
leaf = lowered.split(".")[-1]
|
||||||
|
if (
|
||||||
|
"homeassistant" in lowered
|
||||||
|
or "sqlite" in lowered
|
||||||
|
or leaf in forbidden_targets
|
||||||
|
):
|
||||||
|
offenders.append(f"import {alias.name}")
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
module = (node.module or "").lower()
|
||||||
|
module_leaf = module.split(".")[-1] if module else ""
|
||||||
|
if (
|
||||||
|
"homeassistant" in module
|
||||||
|
or "sqlite" in module
|
||||||
|
or (module and module_leaf in forbidden_targets)
|
||||||
|
or (node.level and module_leaf in forbidden_targets)
|
||||||
|
):
|
||||||
|
offenders.append(
|
||||||
|
f"from {node.module!r} import ... (level={node.level})"
|
||||||
|
)
|
||||||
|
for alias in node.names:
|
||||||
|
alias_leaf = alias.name.split(".")[-1].lower()
|
||||||
|
if alias_leaf in forbidden_targets:
|
||||||
|
offenders.append(
|
||||||
|
f"from {node.module!r} import {alias.name} "
|
||||||
|
f"(level={node.level})"
|
||||||
|
)
|
||||||
|
return offenders
|
||||||
|
|
||||||
|
|
||||||
|
def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
|
||||||
|
"""The shared scan function catches every forbidden import shape,
|
||||||
|
including level-0 absolute package DB imports and 'from . import db'.
|
||||||
|
"""
|
||||||
|
probes = [
|
||||||
|
"from homeassistant.core import HomeAssistant",
|
||||||
|
"import sqlite3",
|
||||||
|
"from .db import NestQuestDatabase",
|
||||||
|
"from custom_components.nestquest.db import NestQuestDatabase",
|
||||||
|
"from custom_components.nestquest import db",
|
||||||
|
"import custom_components.nestquest.dao_rules",
|
||||||
|
"from . import db",
|
||||||
|
"from . import schema",
|
||||||
|
"from custom_components.nestquest.migrations import apply_migrations",
|
||||||
|
]
|
||||||
|
for probe in probes:
|
||||||
|
offenders = _scan_forbidden_imports(probe)
|
||||||
|
assert offenders, f"probe must be caught: {probe!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_string_rule_type_rejected() -> None:
|
||||||
|
"""Only RuleType members and their string values construct."""
|
||||||
|
for bad in (123, True, None, 3.5, b"daily"):
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_rejects_month_and_nth_fields() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY, weekday_set={0}, day_of_month=5,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY, weekday_set={0}, month=3,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_rejects_dangling_nth_weekday_weekday() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.DAILY, nth_weekday_weekday=0,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_monthly_day_rejects_month() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.MONTHLY_DAY, day_of_month=5, month=3,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_yearly_rejects_day_and_nth_fields() -> None:
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
with pytest.raises(RuleValidationError, match="must not set"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.YEARLY, month=6, nth_weekday=1,
|
||||||
|
nth_weekday_weekday=0, start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_value_mapping() -> None:
|
||||||
|
"""The enum exposes the schema's CHECK values via storage_value."""
|
||||||
|
assert RuleType.DAILY.storage_value == "daily"
|
||||||
|
assert RuleType.WEEKLY.storage_value == "weekly"
|
||||||
|
assert RuleType.MONTHLY_DAY.storage_value == "monthly"
|
||||||
|
assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly"
|
||||||
|
assert RuleType.YEARLY.storage_value == "yearly"
|
||||||
|
assert RuleType.CUSTOM_DAYS.storage_value == "custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None:
|
||||||
|
"""[0, False] must NOT collapse False into 0 via set() coercion."""
|
||||||
|
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||||
|
ScheduleRule.from_dict(
|
||||||
|
{"rule_type": "weekly", "weekday_set": [0, False]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_set_with_int_subclass_entry_rejected() -> None:
|
||||||
|
"""A hashable-but-non-plain int subclass is still not a plain int."""
|
||||||
|
|
||||||
|
class IntChild(int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.WEEKLY,
|
||||||
|
weekday_set={IntChild(1)},
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_with_int_subclass_entry_rejected() -> None:
|
||||||
|
class IntChild(int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||||
|
ScheduleRule.from_dict(
|
||||||
|
{"rule_type": "weekly", "weekday_set": [IntChild(1)]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_unknown_key_types_never_leak_typeerror() -> None:
|
||||||
|
"""Mixed unhashable unknown keys must surface as
|
||||||
|
RuleValidationError, not a raw TypeError from sorting."""
|
||||||
|
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
||||||
|
ScheduleRule.from_dict({"rule_type": "daily", 42: "value"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_aliases_and_whitespace_are_not_model_names() -> None:
|
||||||
|
"""The model boundary is exact names only: storage strings, padded
|
||||||
|
and case variants are all rejected."""
|
||||||
|
for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"):
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unhashable_int_subclass_interval_rejected() -> None:
|
||||||
|
class UnhashableInt(int):
|
||||||
|
__hash__ = None
|
||||||
|
|
||||||
|
with pytest.raises(RuleValidationError):
|
||||||
|
ScheduleRule(
|
||||||
|
rule_type=RuleType.DAILY,
|
||||||
|
interval=UnhashableInt(1),
|
||||||
|
start_date="2026-09-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rule_type_is_a_dataclass() -> None:
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
assert dataclasses.is_dataclass(ScheduleRule)
|
||||||
Reference in New Issue
Block a user