Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a2b59d1c | ||
|
|
7f085b140d | ||
|
|
ec0fc29160 |
@@ -50,10 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
runtime_data = domain_data.pop(entry.entry_id, None)
|
||||
if not domain_data:
|
||||
hass.data.pop(DOMAIN, None)
|
||||
runtime_data = hass.data.setdefault(DOMAIN, {}).pop(entry.entry_id, None)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
if runtime_data is not None:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -15,16 +14,6 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> Any:
|
||||
"""Create the options flow handler for NestQuest."""
|
||||
from .options_flow import NestQuestOptionsFlow
|
||||
|
||||
return NestQuestOptionsFlow(config_entry=config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
)
|
||||
|
||||
MIN_HORIZON_DAYS = 1
|
||||
MIN_PANEL_IDLE_TIMEOUT = 30
|
||||
|
||||
_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
|
||||
def _parse_time(value: str) -> bool:
|
||||
"""Return True when value is a valid 24-hour HH:MM string."""
|
||||
return _TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate user input and return field-level errors instead of raising."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
horizon_days = user_input.get(CONF_HORIZON_DAYS)
|
||||
if (
|
||||
isinstance(horizon_days, bool)
|
||||
or not isinstance(horizon_days, int)
|
||||
or horizon_days < MIN_HORIZON_DAYS
|
||||
):
|
||||
errors[CONF_HORIZON_DAYS] = "invalid"
|
||||
|
||||
day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME)
|
||||
if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time):
|
||||
errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time"
|
||||
|
||||
panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT)
|
||||
if (
|
||||
isinstance(panel_idle_timeout, bool)
|
||||
or not isinstance(panel_idle_timeout, int)
|
||||
or panel_idle_timeout < MIN_PANEL_IDLE_TIMEOUT
|
||||
):
|
||||
errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _build_schema(current: dict[str, Any]) -> vol.Schema:
|
||||
"""Build the form schema, pre-filled with the current values."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS]
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME]
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT]
|
||||
): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry):
|
||||
"""Handle the options flow for NestQuest."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the NestQuest options."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=_build_schema(self._current_values())
|
||||
)
|
||||
|
||||
errors = _async_validate(user_input)
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_build_schema(self._current_values()),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title="NestQuest",
|
||||
data={
|
||||
CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS],
|
||||
CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME],
|
||||
CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT],
|
||||
},
|
||||
)
|
||||
|
||||
def _current_values(self) -> dict[str, Any]:
|
||||
"""Resolve current values from entry options, then entry data, then defaults."""
|
||||
options = self.options
|
||||
entry_data = getattr(self.config_entry, "data", None) or {}
|
||||
values: dict[str, Any] = {}
|
||||
for key, default in (
|
||||
(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS),
|
||||
(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME),
|
||||
(CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT),
|
||||
):
|
||||
if key in options:
|
||||
values[key] = options[key]
|
||||
elif key in entry_data:
|
||||
values[key] = entry_data[key]
|
||||
else:
|
||||
values[key] = default
|
||||
return values
|
||||
@@ -1,538 +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")
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 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,
|
||||
):
|
||||
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)
|
||||
# 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,
|
||||
):
|
||||
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
|
||||
@@ -13,22 +13,5 @@
|
||||
"create_entry": {
|
||||
"default": "NestQuest was successfully configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_time": "Time must use 24-hour HH:MM format."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,5 @@
|
||||
"create_entry": {
|
||||
"default": "NestQuest was successfully configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "NestQuest Settings",
|
||||
"description": "Adjust how NestQuest plans, rolls over days, and idles its panel.",
|
||||
"data": {
|
||||
"horizon_days": "Generation horizon (days)",
|
||||
"day_rollover_time": "Day rollover time (HH:MM)",
|
||||
"panel_idle_timeout": "Panel idle timeout (seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"invalid": "Invalid value.",
|
||||
"invalid_time": "Time must use 24-hour HH:MM format."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,8 @@ dependencies = []
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"voluptuous>=0.14",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
"""Shared pytest fixtures for the NestQuest test suite.
|
||||
|
||||
pytest-homeassistant-custom-component could not be adopted: it pins
|
||||
``pytest==9.0.3`` and ``voluptuous==0.13.1``, which conflicts with the dev
|
||||
group's ``voluptuous>=0.14`` (used directly by the options flow's schema and
|
||||
its tests), and its back-releases raise the floor to Python >=3.12 while this
|
||||
project supports >=3.11. The documented fallback is therefore used:
|
||||
``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below.
|
||||
|
||||
conftest.py is the single place where ``homeassistant`` modules are provided:
|
||||
this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent
|
||||
set of MagicMock stand-ins via ``sys.modules`` and wires the parent-package
|
||||
attributes. All of that happens here, before any test module imports the
|
||||
integration, so no test module needs its own ``sys.modules`` setup. It also
|
||||
provides the ``hass`` fixture plus shared helpers used by the behavior tests
|
||||
(config-entry factory, listener registry).
|
||||
|
||||
Run this suite in its own venv *without* homeassistant installed (``uv run
|
||||
pytest -q``); it is never meant to run against a real HA install.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Homeassistant module provisioning (explicitly mock-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
_HA_MODULES = (
|
||||
"homeassistant",
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
)
|
||||
|
||||
def _find_spec(name: str):
|
||||
"""find_spec that returns None when the module (or its parent) is absent."""
|
||||
try:
|
||||
return importlib.util.find_spec(name)
|
||||
except (ModuleNotFoundError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_mock_only():
|
||||
"""Fail fast if real homeassistant is importable: this harness is mock-only."""
|
||||
for _mod in _HA_MODULES:
|
||||
# Anything already in sys.modules at load time is real HA; our mocks
|
||||
# are only installed after this check.
|
||||
if _mod in sys.modules or _find_spec(_mod) is not None:
|
||||
raise pytest.UsageError(
|
||||
"The NestQuest test harness is mock-only: real homeassistant is "
|
||||
f"importable ({_mod!r} found), and mixing real HA modules with the "
|
||||
"shaped mocks is not supported. Run the suite in its own venv "
|
||||
"without homeassistant installed, e.g. `uv run pytest -q`."
|
||||
)
|
||||
|
||||
|
||||
_ensure_mock_only()
|
||||
|
||||
for _mod in _HA_MODULES:
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
# Wire parent package attributes so "from homeassistant import
|
||||
# config_entries" resolves to the mocked submodule, mirroring real HA's
|
||||
# package layout.
|
||||
for _parent, _child in (
|
||||
("homeassistant", "config_entries"),
|
||||
("homeassistant", "data_entry_flow"),
|
||||
("homeassistant", "core"),
|
||||
):
|
||||
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
|
||||
|
||||
|
||||
def _ha_mock(name: str):
|
||||
"""Return the shaped mock module object for a homeassistant module."""
|
||||
return sys.modules[name]
|
||||
|
||||
|
||||
def options_flow_base():
|
||||
"""Return the mocked OptionsFlowWithConfigEntry class."""
|
||||
return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry")
|
||||
|
||||
|
||||
class _ConfigFlowBase:
|
||||
"""Stand-in for homeassistant.config_entries.ConfigFlow.
|
||||
|
||||
A superset covering both the config-flow and options-flow tests: it must
|
||||
satisfy every consumer of the mocked surface.
|
||||
"""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, domain=None, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls._domain = domain
|
||||
|
||||
def __init__(self):
|
||||
self.context = {}
|
||||
|
||||
def async_show_form(self, *, step_id, **kwargs):
|
||||
return {"type": "form", "step_id": step_id, **kwargs}
|
||||
|
||||
def async_abort(self, *, reason, **kwargs):
|
||||
return {"type": "abort", "reason": reason}
|
||||
|
||||
def async_create_entry(self, *, title, data, **kwargs):
|
||||
"""Return the flow result only; the flow manager persists the entry."""
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
async def async_set_unique_id(self, unique_id, raise_on_progress=True):
|
||||
self.context["unique_id"] = unique_id
|
||||
return None
|
||||
|
||||
def _async_current_entries(self):
|
||||
return self.hass.config_entries.async_entries(self._domain)
|
||||
|
||||
def _async_in_progress(self):
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(config_entry):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _OptionsFlowWithConfigEntryBase:
|
||||
"""Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry."""
|
||||
|
||||
def __init__(self, config_entry=None):
|
||||
self.config_entry = config_entry
|
||||
self.hass = None
|
||||
self.options = dict(config_entry.options) if config_entry is not None else {}
|
||||
|
||||
def async_show_form(self, *, step_id, **kwargs):
|
||||
return {"type": "form", "step_id": step_id, **kwargs}
|
||||
|
||||
def async_create_entry(self, *, title, data, **kwargs):
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
|
||||
def _callback_decorator(fn):
|
||||
"""Mirror homeassistant.core.callback: flags the fn and returns it unchanged."""
|
||||
fn.__ha_callback__ = True
|
||||
return fn
|
||||
|
||||
|
||||
# The concrete 2024.6-shaped surface the integration is written against
|
||||
# (mirrors the shapes the tests previously installed per module).
|
||||
_config_entries_mock = _ha_mock("homeassistant.config_entries")
|
||||
_core_mock = _ha_mock("homeassistant.core")
|
||||
_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow")
|
||||
|
||||
_config_entries_mock.ConfigFlow = _ConfigFlowBase
|
||||
_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase
|
||||
_core_mock.callback = _callback_decorator
|
||||
_data_entry_flow_mock.RESULT_TYPE_FORM = "form"
|
||||
_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry"
|
||||
_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
|
||||
_data_entry_flow_mock.FlowResult = dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers for the behavior tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
class ListenerRegistry:
|
||||
"""Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers."""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._listeners: dict[str, list[object]] = {}
|
||||
self.reloaded: list[str] = []
|
||||
|
||||
def add(self, entry_id: str, listener) -> object:
|
||||
def _remove():
|
||||
self._listeners.get(entry_id, []).remove(listener)
|
||||
|
||||
self._listeners.setdefault(entry_id, []).append(listener)
|
||||
return _remove
|
||||
|
||||
async def dispatch_options_update(self, entry) -> None:
|
||||
"""Dispatch on the running loop; sync listeners run inline, async ones awaited."""
|
||||
for listener in list(self._listeners.get(entry.entry_id, [])):
|
||||
result = listener(self._hass, entry)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
async_dispatch_options_update = dispatch_options_update
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(listeners) for listeners in self._listeners.values())
|
||||
|
||||
|
||||
def make_config_entry(
|
||||
entry_id: str = "test_entry",
|
||||
options: dict | None = None,
|
||||
data: dict | None = None,
|
||||
):
|
||||
"""Create a stub config entry shaped like HA's ConfigEntry for tests."""
|
||||
entry = SimpleNamespace(
|
||||
entry_id=entry_id,
|
||||
options=dict(options or {}),
|
||||
data=dict(data or {}),
|
||||
)
|
||||
entry.add_update_listener = None
|
||||
return entry
|
||||
|
||||
|
||||
def wire_entry_to_registry(entry, registry: ListenerRegistry):
|
||||
"""Attach an update-listener registration function to the entry."""
|
||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||
return entry
|
||||
|
||||
|
||||
def make_hass() -> tuple:
|
||||
"""Create a hass stub plus its listener registry, mirroring HA's shape."""
|
||||
hass = SimpleNamespace(
|
||||
data={},
|
||||
config=MagicMock(),
|
||||
config_entries=SimpleNamespace(async_reload=None),
|
||||
)
|
||||
registry = ListenerRegistry(hass)
|
||||
|
||||
async def _async_reload(entry_id: str) -> None:
|
||||
registry.reloaded.append(entry_id)
|
||||
|
||||
hass.config_entries.async_reload = MagicMock(side_effect=_async_reload)
|
||||
hass.config_entries._entries = []
|
||||
hass.config_entries.async_add = (
|
||||
lambda entry: hass.config_entries._entries.append(entry)
|
||||
)
|
||||
|
||||
def _async_entries(domain: str):
|
||||
return [e for e in hass.config_entries._entries if e.domain == domain]
|
||||
|
||||
hass.config_entries.async_entries = _async_entries
|
||||
return hass, registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hass():
|
||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||
|
||||
Mirrors the surface of Home Assistant's ``hass`` fixture: a ``config``
|
||||
namespace, a ``config_entries`` manager wired to a listener registry, a
|
||||
``data`` dict for integration state, and a ``loop`` bound to the
|
||||
pytest-asyncio loop the test itself runs on (HA's running-loop contract),
|
||||
so code scheduling on ``hass.loop`` runs on the actual test loop.
|
||||
"""
|
||||
_hass, registry = make_hass()
|
||||
_hass.registry = registry
|
||||
_hass.loop = asyncio.get_running_loop()
|
||||
yield _hass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_entry():
|
||||
"""Provide the config-entry factory as a fixture."""
|
||||
|
||||
def _factory(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
|
||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def listener_registry():
|
||||
"""Provide a fresh listener registry for manual wiring."""
|
||||
|
||||
def _factory(hass=None):
|
||||
return ListenerRegistry(hass)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_flow():
|
||||
"""Provide a factory for a stubbed NestQuestConfigFlow bound to hass."""
|
||||
|
||||
def _factory(hass=None, existing_entries=None, in_progress=None):
|
||||
from custom_components.nestquest import config_flow
|
||||
|
||||
flow = config_flow.NestQuestConfigFlow()
|
||||
flow.hass = hass if hass is not None else make_hass()[0]
|
||||
for entry in existing_entries or []:
|
||||
if getattr(entry, "domain", None) == flow._domain:
|
||||
flow.hass.config_entries.async_add(entry)
|
||||
if in_progress:
|
||||
flow._async_in_progress = lambda: in_progress
|
||||
return flow
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_config_flow_entry():
|
||||
"""Provide an entry stub carrying a domain, for current-entries checks."""
|
||||
|
||||
def _factory(domain: str, entry_id: str = "existing"):
|
||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
||||
|
||||
return _factory
|
||||
+99
-31
@@ -4,6 +4,58 @@ from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock homeassistant modules if not installed in environment
|
||||
for _mod in (
|
||||
"homeassistant",
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
"homeassistant.data_entry_flow",
|
||||
):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
# Wire parent package attributes to the actual module mocks so that
|
||||
# "from homeassistant import config_entries" resolves to the mocked module.
|
||||
sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"]
|
||||
sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"]
|
||||
|
||||
|
||||
class _ConfigFlowBase:
|
||||
"""Minimal stand-in for homeassistant ConfigFlow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init_subclass__(cls, domain=None, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls._domain = domain
|
||||
|
||||
def __init__(self):
|
||||
self.context = {}
|
||||
|
||||
def async_show_form(self, *, step_id, **kwargs):
|
||||
return {"type": "form", "step_id": step_id}
|
||||
|
||||
def async_abort(self, *, reason, **kwargs):
|
||||
return {"type": "abort", "reason": reason}
|
||||
|
||||
def async_create_entry(self, *, title, data, **kwargs):
|
||||
return {"type": "create_entry", "title": title, "data": data}
|
||||
|
||||
async def async_set_unique_id(self, unique_id, raise_on_progress=True):
|
||||
self.context["unique_id"] = unique_id
|
||||
return None
|
||||
|
||||
def _async_current_entries(self):
|
||||
return self.hass.config_entries.async_entries(self._domain)
|
||||
|
||||
def _async_in_progress(self):
|
||||
return []
|
||||
|
||||
|
||||
sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,63 +67,79 @@ STRINGS_PATH = PKG_DIR / "strings.json"
|
||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||
|
||||
|
||||
async def test_flow_shows_form_initially(hass, make_flow) -> None:
|
||||
def _run(coro):
|
||||
import asyncio
|
||||
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _make_flow(existing_entries: list | None = None, in_progress: list | None = None):
|
||||
"""Create a config flow instance with a stubbed hass."""
|
||||
flow = config_flow.NestQuestConfigFlow()
|
||||
flow.hass = SimpleNamespace(
|
||||
config_entries=SimpleNamespace(
|
||||
async_entries=lambda domain: [
|
||||
entry
|
||||
for entry in (existing_entries or [])
|
||||
if entry.domain == domain
|
||||
]
|
||||
)
|
||||
)
|
||||
if in_progress:
|
||||
flow._async_in_progress = lambda: in_progress
|
||||
return flow
|
||||
|
||||
|
||||
def _make_entry(domain: str):
|
||||
"""Create a stub config entry with the given domain."""
|
||||
return SimpleNamespace(domain=domain)
|
||||
|
||||
|
||||
def test_flow_shows_form_initially() -> None:
|
||||
"""The initial user step shows a form."""
|
||||
flow = make_flow(hass)
|
||||
result = await flow.async_step_user(None)
|
||||
flow = _make_flow()
|
||||
result = _run(flow.async_step_user(None))
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
async def test_flow_creates_entry_on_submit(hass, make_flow) -> None:
|
||||
"""Submitting the user step returns a create-entry result with the payload.
|
||||
|
||||
Real HA's ConfigFlow.async_create_entry only returns the flow result; the
|
||||
flow manager persists the entry afterward. The manager surface therefore
|
||||
stays empty here — persistence is the manager's job, not the flow's.
|
||||
"""
|
||||
flow = make_flow(hass)
|
||||
assert hass.config_entries.async_entries(DOMAIN) == []
|
||||
result = await flow.async_step_user({})
|
||||
def test_flow_creates_entry_on_submit() -> None:
|
||||
"""Submitting the user step creates a config entry."""
|
||||
flow = _make_flow()
|
||||
result = _run(flow.async_step_user({}))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert result["data"] == {}
|
||||
assert hass.config_entries.async_entries(DOMAIN) == []
|
||||
|
||||
|
||||
async def test_second_flow_aborts_single_instance(
|
||||
hass, make_flow, make_config_flow_entry
|
||||
) -> None:
|
||||
def test_second_flow_aborts_single_instance() -> None:
|
||||
"""A second flow aborts with single_instance_allowed."""
|
||||
existing = make_config_flow_entry(DOMAIN)
|
||||
flow = make_flow(hass, existing_entries=[existing])
|
||||
assert hass.config_entries.async_entries(DOMAIN) == [existing]
|
||||
result = await flow.async_step_user(None)
|
||||
flow = _make_flow([_make_entry(DOMAIN)])
|
||||
result = _run(flow.async_step_user(None))
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
assert hass.config_entries.async_entries(DOMAIN) == [existing]
|
||||
|
||||
|
||||
async def test_flow_aborts_when_in_progress(hass, make_flow) -> None:
|
||||
def test_flow_aborts_when_in_progress() -> None:
|
||||
"""A concurrent flow in progress aborts with single_instance_allowed."""
|
||||
flow = make_flow(hass, in_progress=[{"flow_id": "other"}])
|
||||
result = await flow.async_step_user(None)
|
||||
flow = _make_flow(in_progress=[{"flow_id": "other"}])
|
||||
result = _run(flow.async_step_user(None))
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_flow_sets_stable_unique_id(hass, make_flow) -> None:
|
||||
def test_flow_sets_stable_unique_id() -> None:
|
||||
"""The flow assigns a stable unique id equal to the domain."""
|
||||
flow = make_flow(hass)
|
||||
flow = _make_flow()
|
||||
assert flow.context.get("unique_id") is None
|
||||
await flow.async_step_user(None)
|
||||
_run(flow.async_step_user(None))
|
||||
assert flow.context["unique_id"] == DOMAIN
|
||||
|
||||
|
||||
async def test_flow_ignores_other_domain_entries(hass, make_flow, make_config_flow_entry) -> None:
|
||||
def test_flow_ignores_other_domain_entries() -> None:
|
||||
"""Entries of other domains do not trigger the abort."""
|
||||
flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")])
|
||||
result = await flow.async_step_user(None)
|
||||
flow = _make_flow([_make_entry("other_domain")])
|
||||
result = _run(flow.async_step_user(None))
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ import ast
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock homeassistant modules if not installed in environment
|
||||
for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
from custom_components.nestquest import const
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Contract test for the mock-only homeassistant harness in conftest.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
|
||||
from conftest import _HA_MODULES, _ensure_mock_only
|
||||
|
||||
|
||||
def test_mock_only_harness_contract(monkeypatch):
|
||||
"""The harness is mock-only: shaped mocks everywhere, fail-fast on real HA."""
|
||||
# Every required homeassistant module in sys.modules is a mock stand-in
|
||||
# with the shaped surface, and parent-package attributes point at them.
|
||||
for name in _HA_MODULES:
|
||||
assert isinstance(sys.modules[name], MagicMock), f"{name} is not a mock"
|
||||
|
||||
config_entries = sys.modules["homeassistant.config_entries"]
|
||||
assert config_entries.ConfigFlow is conftest._ConfigFlowBase
|
||||
assert config_entries.OptionsFlowWithConfigEntry is (
|
||||
conftest._OptionsFlowWithConfigEntryBase
|
||||
)
|
||||
assert sys.modules["homeassistant"].config_entries is config_entries
|
||||
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
|
||||
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
|
||||
|
||||
# No homeassistant distribution is installed in this suite's own venv.
|
||||
with pytest.raises(importlib.metadata.PackageNotFoundError):
|
||||
importlib.metadata.version("homeassistant")
|
||||
|
||||
# Fail-fast paths: a findable real HA module, or one preloaded in
|
||||
# sys.modules, both abort collection with a UsageError.
|
||||
real_spec = MagicMock(origin="/fake/site-packages/homeassistant/__init__.py")
|
||||
monkeypatch.setattr(conftest, "_find_spec", lambda name: real_spec)
|
||||
with pytest.raises(pytest.UsageError, match="mock-only"):
|
||||
_ensure_mock_only()
|
||||
|
||||
monkeypatch.setattr(conftest, "_find_spec", lambda name: None)
|
||||
monkeypatch.setitem(sys.modules, "homeassistant", MagicMock(name="real-module"))
|
||||
with pytest.raises(pytest.UsageError, match="mock-only"):
|
||||
_ensure_mock_only()
|
||||
|
||||
|
||||
async def test_hass_and_registry_run_on_the_running_test_loop(hass, make_entry):
|
||||
"""hass.loop is the test's own loop and the registry spins no private loop."""
|
||||
# hass.loop is the very loop the async test runs on (HA running-loop
|
||||
# contract): code scheduling on hass.loop lands on the test loop itself.
|
||||
assert hass.loop is asyncio.get_running_loop()
|
||||
ran = []
|
||||
|
||||
async def _scheduled():
|
||||
ran.append(True)
|
||||
|
||||
await hass.loop.create_task(_scheduled())
|
||||
assert ran == [True]
|
||||
|
||||
# ListenerRegistry never spins its own event loop (no resource leak) and
|
||||
# dispatch is a plain coroutine: sync listeners run inline, async awaited.
|
||||
registry = hass.registry
|
||||
assert not hasattr(registry, "_loop")
|
||||
assert inspect.iscoroutinefunction(registry.dispatch_options_update)
|
||||
|
||||
calls = []
|
||||
|
||||
def _sync_listener(h, entry):
|
||||
calls.append(("sync", entry.entry_id, h is hass))
|
||||
|
||||
entry = make_entry(entry_id="disp")
|
||||
registry.add("disp", _sync_listener)
|
||||
await registry.dispatch_options_update(entry)
|
||||
assert calls == [("sync", "disp", True)]
|
||||
|
||||
async def _async_listener(h, entry):
|
||||
calls.append(("async", entry.entry_id))
|
||||
|
||||
registry.add("disp", _async_listener)
|
||||
calls.clear()
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
assert calls == [("sync", "disp", True), ("async", "disp")]
|
||||
+174
-123
@@ -2,198 +2,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||
# Mock homeassistant modules if not installed in environment
|
||||
for _mod in (
|
||||
"homeassistant",
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
# Wire parent package attributes to the actual module mocks so that
|
||||
# "from homeassistant import config_entries" resolves to the mocked module.
|
||||
sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"]
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
|
||||
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
|
||||
|
||||
|
||||
def _wire(entry, registry):
|
||||
return wire_entry_to_registry(entry, registry)
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
class _ListenerRegistry:
|
||||
"""Models HA's update-listener bookkeeping: appends to a list per entry, with per-listener removers."""
|
||||
|
||||
def __init__(self, hass=None):
|
||||
self._hass = hass
|
||||
self._listeners: dict[str, list[object]] = {}
|
||||
self.reloaded: list[str] = []
|
||||
self._loop = asyncio.new_event_loop()
|
||||
|
||||
def add(self, entry_id: str, listener) -> object:
|
||||
def _remove():
|
||||
self._listeners.get(entry_id, []).remove(listener)
|
||||
|
||||
self._listeners.setdefault(entry_id, []).append(listener)
|
||||
return _remove
|
||||
|
||||
def dispatch_options_update(self, entry) -> None:
|
||||
for listener in list(self._listeners.get(entry.entry_id, [])):
|
||||
result = listener(self._hass, entry)
|
||||
if inspect.isawaitable(result):
|
||||
self._loop.run_until_complete(result)
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return sum(len(listeners) for listeners in self._listeners.values())
|
||||
|
||||
|
||||
def _make_hass():
|
||||
hass = SimpleNamespace(
|
||||
data={},
|
||||
config_entries=SimpleNamespace(
|
||||
async_reload=None,
|
||||
),
|
||||
)
|
||||
registry = _ListenerRegistry(hass)
|
||||
async def _async_reload(entry_id: str) -> None:
|
||||
registry.reloaded.append(entry_id)
|
||||
|
||||
hass.config_entries.async_reload = MagicMock(side_effect=_async_reload)
|
||||
return hass, registry
|
||||
|
||||
|
||||
def _make_entry(entry_id: str = "test_entry", options: dict | None = None):
|
||||
entry = SimpleNamespace(entry_id=entry_id, options=dict(options or {}))
|
||||
entry.add_update_listener = None
|
||||
return entry
|
||||
|
||||
|
||||
def _wire_entry(entry, registry: _ListenerRegistry):
|
||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||
return entry
|
||||
|
||||
|
||||
def test_setup_stores_runtime_data_in_hass_data() -> None:
|
||||
hass, _ = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), _ListenerRegistry())
|
||||
assert _run(async_setup_entry(hass, entry)) is True
|
||||
assert entry.entry_id in hass.data[DOMAIN]
|
||||
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
|
||||
|
||||
|
||||
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
def test_setup_runtime_data_has_entry_id_and_options() -> None:
|
||||
hass, _ = _make_hass()
|
||||
entry = _wire_entry(_make_entry(options={"horizon_days": 7}), _ListenerRegistry())
|
||||
_run(async_setup_entry(hass, entry))
|
||||
runtime = entry.runtime_data
|
||||
assert runtime.entry_id == entry.entry_id
|
||||
assert runtime.options == {"horizon_days": 7}
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert registry.size == 1
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert hass.data == {}
|
||||
assert DOMAIN not in hass.data
|
||||
assert entry.runtime_data is None
|
||||
assert registry.size == 0
|
||||
def test_unload_returns_true_and_removes_hass_data() -> None:
|
||||
hass, _ = _make_hass()
|
||||
registry = _ListenerRegistry()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
assert _run(async_unload_entry(hass, entry)) is True
|
||||
assert entry.entry_id not in hass.data[DOMAIN]
|
||||
|
||||
|
||||
async def test_unload_of_one_entry_keeps_domain_while_other_remains(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""Unloading one of two entries keeps DOMAIN alive with the other entry."""
|
||||
registry = hass.registry
|
||||
entry_a = _wire(make_entry(entry_id="entry_a"), registry)
|
||||
entry_b = _wire(make_entry(entry_id="entry_b"), registry)
|
||||
assert await async_setup_entry(hass, entry_a) is True
|
||||
assert await async_setup_entry(hass, entry_b) is True
|
||||
assert set(hass.data[DOMAIN]) == {"entry_a", "entry_b"}
|
||||
|
||||
assert await async_unload_entry(hass, entry_a) is True
|
||||
assert DOMAIN in hass.data
|
||||
assert set(hass.data[DOMAIN]) == {"entry_b"}
|
||||
assert hass.data[DOMAIN]["entry_b"] is entry_b.runtime_data
|
||||
assert entry_a.runtime_data is None
|
||||
assert entry_b.runtime_data is not None
|
||||
assert registry.size == 1
|
||||
|
||||
assert await async_unload_entry(hass, entry_b) is True
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.data == {}
|
||||
assert entry_b.runtime_data is None
|
||||
assert registry.size == 0
|
||||
def test_unload_is_idempotent() -> None:
|
||||
hass, _ = _make_hass()
|
||||
registry = _ListenerRegistry()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
assert _run(async_unload_entry(hass, entry)) is True
|
||||
assert _run(async_unload_entry(hass, entry)) is True
|
||||
assert entry.entry_id not in hass.data[DOMAIN]
|
||||
|
||||
|
||||
async def test_unload_is_idempotent(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.data == {}
|
||||
def test_unload_without_setup_returns_true() -> None:
|
||||
hass, _ = _make_hass()
|
||||
entry = _make_entry()
|
||||
assert _run(async_unload_entry(hass, entry)) is True
|
||||
assert DOMAIN not in hass.data or entry.entry_id not in hass.data[DOMAIN]
|
||||
|
||||
|
||||
async def test_unload_without_setup_returns_true(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert DOMAIN not in hass.data
|
||||
assert hass.data == {}
|
||||
|
||||
|
||||
async def test_unload_clears_entry_runtime_data(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
def test_unload_clears_entry_runtime_data() -> None:
|
||||
hass, _ = _make_hass()
|
||||
registry = _ListenerRegistry()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
_run(async_unload_entry(hass, entry))
|
||||
assert entry.runtime_data is None
|
||||
|
||||
|
||||
async def test_unload_removes_update_listener(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
def test_unload_removes_update_listener() -> None:
|
||||
hass, _ = _make_hass()
|
||||
registry = _ListenerRegistry()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
assert registry.size == 1
|
||||
await async_unload_entry(hass, entry)
|
||||
_run(async_unload_entry(hass, entry))
|
||||
assert registry.size == 0
|
||||
|
||||
|
||||
async def test_options_update_triggers_reload_once(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
def test_options_update_triggers_reload_once() -> None:
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
entry.options = {"horizon_days": 30}
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
registry.dispatch_options_update(entry)
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
|
||||
|
||||
async def test_no_reload_after_unload(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
def test_no_reload_after_unload() -> None:
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
_run(async_unload_entry(hass, entry))
|
||||
registry.dispatch_options_update(entry)
|
||||
assert hass.config_entries.async_reload.call_count == 0
|
||||
|
||||
|
||||
async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
def test_three_reload_cycles_keep_single_listener() -> None:
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
for _ in range(3):
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert _run(async_setup_entry(hass, entry)) is True
|
||||
assert registry.size == 1
|
||||
assert await async_unload_entry(hass, entry) is True
|
||||
assert _run(async_unload_entry(hass, entry)) is True
|
||||
assert registry.size == 0
|
||||
assert hass.config_entries.async_reload.call_count == 0
|
||||
|
||||
|
||||
async def test_three_setup_unload_cycles_leave_no_listeners(hass, make_entry) -> None:
|
||||
def test_three_setup_unload_cycles_leave_no_listeners() -> None:
|
||||
"""Listeners accumulate per entry (HA semantics); unload must remove them all."""
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
_run(async_unload_entry(hass, entry))
|
||||
assert registry._listeners.get(entry.entry_id, []) == []
|
||||
assert registry.size == 0
|
||||
|
||||
|
||||
async def test_options_update_after_each_setup_invokes_exactly_one_listener(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
def test_options_update_after_each_setup_invokes_exactly_one_listener() -> None:
|
||||
"""After each setup, an options update dispatches to exactly one listener."""
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
registry.dispatch_options_update(entry)
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
await async_unload_entry(hass, entry)
|
||||
_run(async_unload_entry(hass, entry))
|
||||
hass.config_entries.async_reload.reset_mock()
|
||||
registry.reloaded.clear()
|
||||
|
||||
|
||||
async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None:
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
def test_reload_setup_cycles_dispatch_reload_each_time() -> None:
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
_run(async_setup_entry(hass, entry))
|
||||
registry.dispatch_options_update(entry)
|
||||
_run(async_unload_entry(hass, entry))
|
||||
assert hass.config_entries.async_reload.call_count == 3
|
||||
assert registry.size == 0
|
||||
|
||||
|
||||
async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
||||
assert pending == []
|
||||
def test_no_orphaned_tasks_after_unload() -> None:
|
||||
async def _scenario():
|
||||
hass, registry = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
await async_unload_entry(hass, entry)
|
||||
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
||||
assert pending == []
|
||||
|
||||
_run(_scenario())
|
||||
|
||||
|
||||
async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
def test_setup_entry_uses_domain_constant() -> None:
|
||||
hass, _ = _make_hass()
|
||||
entry = _wire_entry(_make_entry(), _ListenerRegistry())
|
||||
_run(async_setup_entry(hass, entry))
|
||||
assert set(hass.data) == {DOMAIN_CONST}
|
||||
|
||||
|
||||
async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
"""A coroutine-returning remover is awaited during unload."""
|
||||
removed = []
|
||||
|
||||
async def _remover():
|
||||
removed.append(True)
|
||||
async def _scenario():
|
||||
removed = []
|
||||
|
||||
entry = make_config_entry(entry_id="awaitable")
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass, _registry = make_hass()
|
||||
await async_setup_entry(hass, entry)
|
||||
result = await async_unload_entry(hass, entry)
|
||||
assert result is True
|
||||
assert removed == [True]
|
||||
assert hass.data == {}
|
||||
assert entry.runtime_data is None
|
||||
async def _remover():
|
||||
removed.append(True)
|
||||
|
||||
entry = SimpleNamespace(entry_id="awaitable", options={})
|
||||
entry.add_update_listener = lambda listener: _remover
|
||||
hass = SimpleNamespace(data={}, config_entries=SimpleNamespace(async_reload=None))
|
||||
await async_setup_entry(hass, entry)
|
||||
result = await async_unload_entry(hass, entry)
|
||||
assert result is True
|
||||
assert removed == [True]
|
||||
|
||||
_run(_scenario())
|
||||
@@ -3,6 +3,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock homeassistant modules if not installed in environment
|
||||
for _mod in ("homeassistant", "homeassistant.core", "homeassistant.config_entries"):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
"""Tests for the options flow of NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base
|
||||
|
||||
from custom_components.nestquest import config_flow
|
||||
from custom_components.nestquest import options_flow
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_PANEL_IDLE_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
PKG_DIR = Path(options_flow.__file__).parent
|
||||
STRINGS_PATH = PKG_DIR / "strings.json"
|
||||
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
|
||||
|
||||
VALID_INPUT = {
|
||||
CONF_HORIZON_DAYS: 7,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 60,
|
||||
}
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _make_hass():
|
||||
return make_hass()
|
||||
|
||||
|
||||
def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
|
||||
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
||||
|
||||
|
||||
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
||||
"""Create an options flow instance bound to a stubbed config entry."""
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
flow.hass = SimpleNamespace()
|
||||
return flow
|
||||
|
||||
|
||||
def _schema_values(schema) -> dict:
|
||||
"""Extract the per-field default values from a real voluptuous schema."""
|
||||
return {
|
||||
marker.schema: marker.default()
|
||||
for marker in schema.schema
|
||||
if hasattr(marker, "schema")
|
||||
}
|
||||
|
||||
|
||||
def test_options_flow_reachable_via_configure_button() -> None:
|
||||
"""The config flow exposes async_get_options_flow returning the options flow."""
|
||||
entry = _make_entry()
|
||||
handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry)
|
||||
assert isinstance(handler, options_flow.NestQuestOptionsFlow)
|
||||
assert handler.config_entry is entry
|
||||
|
||||
|
||||
def test_options_flow_get_options_flow_is_ha_callback() -> None:
|
||||
"""async_get_options_flow is wrapped by the homeassistant.core.callback decorator."""
|
||||
assert getattr(
|
||||
config_flow.NestQuestConfigFlow.async_get_options_flow, "__ha_callback__", False
|
||||
) is True
|
||||
|
||||
|
||||
def test_options_flow_constructs_through_2024_6_surface() -> None:
|
||||
"""The handler constructs against the mocked 2024.6 base and inherits its contract."""
|
||||
entry = _make_entry(options={CONF_HORIZON_DAYS: 21})
|
||||
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
||||
assert isinstance(flow, _options_flow_base())
|
||||
assert flow.config_entry is entry
|
||||
assert flow.options == {CONF_HORIZON_DAYS: 21}
|
||||
|
||||
|
||||
def test_options_flow_submits_through_2024_6_surface() -> None:
|
||||
"""The handler can be submitted through the mocked 2024.6-shaped surface."""
|
||||
entry = _make_entry()
|
||||
handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry)
|
||||
result = _run(handler.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert result["data"] == VALID_INPUT
|
||||
|
||||
|
||||
def test_options_flow_shows_form_initially() -> None:
|
||||
"""The init step shows a form."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
|
||||
def test_options_flow_schema_is_voluptuous() -> None:
|
||||
"""The form's data_schema is a real voluptuous Schema (no dict fallback)."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
import voluptuous as vol
|
||||
|
||||
assert isinstance(result["data_schema"], vol.Schema)
|
||||
|
||||
|
||||
def test_options_flow_prefills_from_defaults() -> None:
|
||||
"""Without stored options, the form pre-fills with the constant defaults."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
||||
|
||||
|
||||
def test_options_flow_prefills_from_entry_options() -> None:
|
||||
"""Stored entry options win over entry data and defaults."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
},
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 99,
|
||||
CONF_DAY_ROLLOVER_TIME: "23:59",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 999,
|
||||
},
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 21
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "05:45"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
|
||||
|
||||
|
||||
def test_options_flow_falls_back_to_entry_data() -> None:
|
||||
"""Pre-upgrade entries without options fall back to entry.data."""
|
||||
entry = _make_entry(
|
||||
data={
|
||||
CONF_HORIZON_DAYS: 10,
|
||||
CONF_DAY_ROLLOVER_TIME: "01:15",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 90,
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
values = _schema_values(result["data_schema"])
|
||||
assert values[CONF_HORIZON_DAYS] == 10
|
||||
assert values[CONF_DAY_ROLLOVER_TIME] == "01:15"
|
||||
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
|
||||
|
||||
|
||||
def test_options_flow_schema_applies_stored_defaults() -> None:
|
||||
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
||||
entry = _make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
}
|
||||
)
|
||||
result = _run(_make_flow(entry).async_step_init(None))
|
||||
assert result["data_schema"]({}) == {
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "05:45",
|
||||
CONF_PANEL_IDLE_TIMEOUT: 120,
|
||||
}
|
||||
|
||||
|
||||
def test_options_flow_schema_coerces_field_types() -> None:
|
||||
"""The schema validates the expected field types via voluptuous."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(None))
|
||||
schema = result["data_schema"]
|
||||
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
||||
assert validators[CONF_HORIZON_DAYS] is int
|
||||
assert validators[CONF_DAY_ROLLOVER_TIME] is str
|
||||
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_horizon", [0, -5])
|
||||
def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None:
|
||||
"""Zero or negative horizon values yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_not_a_time() -> None:
|
||||
"""Non-HH:MM strings are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_malformed_time_out_of_range() -> None:
|
||||
"""Out-of-range HH:MM values are rejected with invalid_time."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
def test_options_flow_rejects_pasted_unicode_superscript_time() -> None:
|
||||
"""Pasted Unicode digits like '²²:²²' yield a field-level error, no exception."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10])
|
||||
def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None:
|
||||
"""Non-numeric or too-low idle timeouts yield a field-level error."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(
|
||||
flow.async_step_init({**VALID_INPUT, CONF_PANEL_IDLE_TIMEOUT: bad_timeout})
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"}
|
||||
|
||||
|
||||
def test_options_flow_creates_entry_with_three_keys() -> None:
|
||||
"""A valid submit creates an entry with exactly the three validated keys."""
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == "NestQuest"
|
||||
assert set(result["data"]) == {
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_PANEL_IDLE_TIMEOUT,
|
||||
}
|
||||
assert result["data"] == VALID_INPUT
|
||||
|
||||
|
||||
def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None:
|
||||
"""The flow's created data is written to entry.options and reloads exactly once."""
|
||||
hass, registry = _make_hass()
|
||||
entry = _make_entry()
|
||||
entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener)
|
||||
|
||||
# Simulate setup registering the merged lifecycle update listener.
|
||||
from custom_components.nestquest import async_setup_entry
|
||||
|
||||
_run(async_setup_entry(hass, entry))
|
||||
|
||||
# Run the options flow and apply its created data to entry.options like HA does.
|
||||
flow = _make_flow(entry)
|
||||
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
||||
assert result["type"] == "create_entry"
|
||||
entry.options = dict(result["data"])
|
||||
|
||||
_run(registry.dispatch_options_update(entry))
|
||||
assert hass.config_entries.async_reload.call_count == 1
|
||||
assert registry.reloaded == [entry.entry_id]
|
||||
assert entry.options == VALID_INPUT
|
||||
|
||||
|
||||
def test_options_flow_validation_never_raises() -> None:
|
||||
"""Validation returns errors instead of raising for arbitrary junk input."""
|
||||
junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None}
|
||||
flow = _make_flow(_make_entry())
|
||||
result = _run(flow.async_step_init(junk))
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {
|
||||
CONF_HORIZON_DAYS: "invalid",
|
||||
CONF_DAY_ROLLOVER_TIME: "invalid_time",
|
||||
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def strings() -> dict:
|
||||
"""Load and parse strings.json."""
|
||||
assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}"
|
||||
with open(STRINGS_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def translations_en() -> dict:
|
||||
"""Load and parse translations/en.json."""
|
||||
assert TRANSLATIONS_EN_PATH.exists(), (
|
||||
f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}"
|
||||
)
|
||||
with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def test_options_strings_present_and_matching(strings: dict, translations_en: dict) -> None:
|
||||
"""Both string files carry identical options step/error strings."""
|
||||
assert strings.get("options") == translations_en.get("options")
|
||||
options = strings["options"]
|
||||
init = options["step"]["init"]
|
||||
assert init["title"]
|
||||
assert init["description"]
|
||||
data = init["data"]
|
||||
for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT):
|
||||
assert data.get(key), f"Missing field name for '{key}'"
|
||||
errors = options["errors"]
|
||||
for key in ("invalid", "invalid_time"):
|
||||
assert errors.get(key), f"Missing error string for '{key}'"
|
||||
|
||||
|
||||
def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> None:
|
||||
"""No placeholder/raw keys appear in the options strings."""
|
||||
|
||||
def check(value) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, val in value.items():
|
||||
assert val, f"Empty string value for key '{key}'"
|
||||
check(val)
|
||||
elif isinstance(value, str):
|
||||
assert value.strip() != ""
|
||||
|
||||
check(strings["options"])
|
||||
check(translations_en["options"])
|
||||
|
||||
|
||||
def test_options_flow_no_plain_dict_schema_fallback() -> None:
|
||||
"""No try/except ImportError fallback for voluptuous in the options flow module."""
|
||||
py_file = Path(options_flow.__file__)
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Try):
|
||||
handlers = node.handlers or []
|
||||
has_import_error_handler = any(
|
||||
getattr(handler.type, "id", getattr(handler.type, "attr", None))
|
||||
== "ImportError"
|
||||
for handler in handlers
|
||||
)
|
||||
assert not has_import_error_handler, (
|
||||
f"Plain-dict schema fallback (try/except ImportError) found in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
|
||||
def test_options_flow_subclasses_options_flow_with_config_entry() -> None:
|
||||
"""NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry."""
|
||||
base = _options_flow_base()
|
||||
assert issubclass(options_flow.NestQuestOptionsFlow, base)
|
||||
assert options_flow.NestQuestOptionsFlow.__bases__ == (base,)
|
||||
|
||||
|
||||
def test_options_flow_no_hardcoded_domain_or_db_filename() -> None:
|
||||
"""The options flow module does not hardcode the domain or db filename."""
|
||||
py_file = Path(options_flow.__file__)
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
assert node.value != DOMAIN, (
|
||||
f"Hard-coded domain string in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
assert node.value != "nestquest.db", (
|
||||
f"Hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
@@ -1,295 +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_unimplemented_shapes_loudly() -> None:
|
||||
"""Monthly/yearly land with their own engine tasks; a loud
|
||||
NotImplementedError beats a silent wrong answer."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
with pytest.raises(NotImplementedError):
|
||||
occurs_on(rule, _d("2026-09-15"))
|
||||
|
||||
def test_interval_7_preserves_weekday_alignment_across_months() -> None:
|
||||
"""Weekly-by-interval semantics: every firing lands on the SAME
|
||||
weekday as start_date, no matter how many month edges intervene."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01"
|
||||
)
|
||||
# 2026-09-01 is a Tuesday; every firing must be a Tuesday too.
|
||||
assert _d("2026-09-01").weekday() == 1
|
||||
for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"):
|
||||
assert occurs_on(rule, _d(day)) is True, day
|
||||
assert _d(day).weekday() == 1, f"{day} must be a Tuesday"
|
||||
# The day after a firing is never a firing.
|
||||
assert occurs_on(rule, _d("2026-10-07")) is False
|
||||
|
||||
|
||||
def test_interval_2_across_leap_day() -> None:
|
||||
"""Interval > 1 arithmetic across a leap day: the extra day shifts
|
||||
subsequent offsets by one, and the mod arithmetic reflects it."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
|
||||
)
|
||||
assert occurs_on(rule, _d("2028-02-27")) is True # 0
|
||||
assert occurs_on(rule, _d("2028-02-28")) is False # 1
|
||||
assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day)
|
||||
assert occurs_on(rule, _d("2028-03-01")) is False # 3
|
||||
assert occurs_on(rule, _d("2028-03-02")) is True # 4
|
||||
|
||||
|
||||
def test_occurrences_between_leap_day_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
|
||||
)
|
||||
result = occurrences_between(rule, "2028-02-27", "2028-03-03")
|
||||
assert result == ["2028-02-27", "2028-02-29", "2028-03-02"]
|
||||
|
||||
|
||||
def test_occurrences_between_year_boundary_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
|
||||
)
|
||||
result = occurrences_between(rule, "2026-12-30", "2027-01-04")
|
||||
# Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are
|
||||
# Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets:
|
||||
assert result == ["2026-12-30", "2027-01-01", "2027-01-03"]
|
||||
|
||||
|
||||
def test_occurs_on_unimplemented_shape_raises_even_outside_window() -> None:
|
||||
"""An unimplemented shape must raise whether the date is inside or
|
||||
outside the window — silent False would hide a missing engine."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6,
|
||||
start_date="2026-09-01", end_date="2026-09-10",
|
||||
)
|
||||
# Before start, inside window, after end: all must raise.
|
||||
for day in ("2026-08-31", "2026-09-07", "2026-09-20"):
|
||||
with pytest.raises(NotImplementedError):
|
||||
occurs_on(rule, _d(day))
|
||||
|
||||
|
||||
def test_occurrences_between_unimplemented_shape_raises_even_disjoint(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6,
|
||||
start_date="2026-09-01"
|
||||
)
|
||||
# A range entirely before the rule's start still raises: the shape
|
||||
# is unimplemented, and silence would hide it.
|
||||
with pytest.raises(NotImplementedError):
|
||||
occurrences_between(rule, "2026-08-01", "2026-08-10")
|
||||
@@ -1,480 +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, 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)
|
||||
@@ -1,301 +0,0 @@
|
||||
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_month_end,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nominal-day firings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_day_15_fires_on_the_15th() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
for month in range(1, 13):
|
||||
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_on_other_days() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
|
||||
start_date="2026-01-10",
|
||||
)
|
||||
# Every non-10th day of a short month must be False.
|
||||
for day in range(1, 30):
|
||||
if day != 10:
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, 1, day)
|
||||
) is False, f"Jan {day}"
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15",
|
||||
)
|
||||
# Feb 15 precedes the start: no firing even though the day matches.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15", end_date="2026-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Month-end clamp policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_month_end_helper_is_leap_aware() -> None:
|
||||
assert _month_end(2026, 1) == 31
|
||||
assert _month_end(2026, 4) == 30
|
||||
assert _month_end(2026, 2) == 28 # non-leap
|
||||
assert _month_end(2028, 2) == 29 # leap
|
||||
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
|
||||
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
|
||||
assert _month_end(2026, 12) == 31
|
||||
|
||||
|
||||
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Months with a 31st: fire on the 31st.
|
||||
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
|
||||
(10, 31), (12, 31)):
|
||||
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
|
||||
f"2026-{month:02d}-{day:02d}"
|
||||
)
|
||||
# Months WITHOUT a 31st: fire on the month's last day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
|
||||
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
|
||||
# The day BEFORE the clamp is not a firing.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_clamps_to_feb_28_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2026-01-30",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
|
||||
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
|
||||
# Months with a 30th: nominal day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
|
||||
|
||||
|
||||
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2026-01-29",
|
||||
)
|
||||
# Non-leap 2027: Feb 28 is the clamp.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
|
||||
# Leap 2028: Feb 29 exists — nominal day.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_feb_leap_year_clamps_to_29() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2027-01-30",
|
||||
)
|
||||
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
|
||||
"""Every (month, day) in ``rule``'s year the engine says fires."""
|
||||
hits = []
|
||||
for month in range(1, 13):
|
||||
for day in range(1, _month_end(year, month) + 1):
|
||||
if occurs_on(rule, datetime.date(year, month, day)):
|
||||
hits.append((month, day))
|
||||
return hits
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2026() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Exactly one firing per month, twelve total.
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
assert len(hits) == 12
|
||||
by_month = dict(hits)
|
||||
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
|
||||
assert by_month[2] == 28 # 2026 Feb is non-leap
|
||||
assert by_month[4] == 30
|
||||
# Each month has exactly one firing.
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2028-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap February clamps to the 29th
|
||||
assert by_month[4] == 30
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_30_sweep_2028() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2028-01-30",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb clamps to 29
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2027_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2027-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2027)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2028-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_interval_2_every_second_month() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
|
||||
"""Interval counts nominal months from the anchor: Jan 31 anchor,
|
||||
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
|
||||
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
|
||||
|
||||
|
||||
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-11-15",
|
||||
)
|
||||
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
|
||||
assert result == [
|
||||
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
|
||||
"2026-05-31", "2026-06-30",
|
||||
]
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-06-15", "2026-01-15")
|
||||
|
||||
|
||||
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
|
||||
"""The clamp must not double-fire: only one day per month matches."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
months = [month for month, _ in hits]
|
||||
assert months == sorted(months)
|
||||
assert len(months) == len(set(months)) == 12
|
||||
@@ -1,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
|
||||
@@ -4,8 +4,17 @@ from __future__ import annotations
|
||||
import ast
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
# Mock homeassistant modules if not installed in environment
|
||||
for _mod in (
|
||||
"homeassistant",
|
||||
"homeassistant.core",
|
||||
"homeassistant.config_entries",
|
||||
):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
from custom_components.nestquest import store
|
||||
from custom_components.nestquest.const import SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nestquest"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "voluptuous" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||
{ name = "voluptuous", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "voluptuous"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/92/f4/0738e6849858deae22218be3bbb8207ba83a96e9d0ec7e8e8cd67b30e5ca/voluptuous-0.16.0.tar.gz", hash = "sha256:006535e22fed944aec17bef6e8725472476194743c87bd233e912eb463f8ff05", size = 54238, upload-time = "2025-12-18T23:18:46.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/00/0e0da784245c93cf346150ab67634177bf277f93b7a162bb56c928c39c04/voluptuous-0.16.0-py3-none-any.whl", hash = "sha256:ee342095263e1b5afbd4d418cb5adc92810eebfd07696bb033a261210df33db4", size = 31931, upload-time = "2025-12-18T23:18:44.694Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user