Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc3f26eb1 | ||
|
|
a6a21af013 | ||
|
|
2146dbb85a | ||
|
|
e126e20849 | ||
|
|
5a962e0ed7 | ||
|
|
db4f695541 | ||
|
|
d81f38528a | ||
|
|
23a52707af | ||
|
|
3b638cae6f | ||
|
|
ceb32074e7 | ||
|
|
221222275a | ||
|
|
b854013f1a | ||
|
|
c75de71f37 | ||
|
|
e5cda26e11 | ||
|
|
3b9ffa383f | ||
|
|
78f783cfad | ||
|
|
f787d04a6b | ||
|
|
ec888b6426 | ||
|
|
5bcaa54934 | ||
|
|
8d04c95314 | ||
|
|
20fc696f8e |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NestQuest Contributors / Maniacs Mansion
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,68 @@
|
||||
# NestQuest - House Chaos Coordination
|
||||
|
||||
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
||||
|
||||
## Overview
|
||||
|
||||
NestQuest helps households coordinate daily life, chore tracking, custody schedules, and shared tasks directly within Home Assistant. It provides sensors, calendar events, and service calls designed for busy households and modern co-parenting setups.
|
||||
|
||||
## Installation
|
||||
|
||||
### HACS Installation (Recommended)
|
||||
|
||||
1. Ensure [HACS (Home Assistant Community Store)](https://hacs.xyz/) is installed.
|
||||
2. In Home Assistant, open **HACS** > **Integrations**.
|
||||
3. Select the three-dots menu in the top right and click **Custom repositories**.
|
||||
4. Enter the repository URL: `https://github.com/talon2king/NestQuest`
|
||||
5. Select category **Integration** and click **Add**.
|
||||
6. Search for **NestQuest** and click **Download**.
|
||||
7. Restart Home Assistant.
|
||||
|
||||
> **Note:** Development happens in the private Gitea repository
|
||||
> (`https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest`). The GitHub
|
||||
> repository is a public mirror maintained for HACS distribution.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download the latest release from the repository.
|
||||
2. Copy the `custom_components/nestquest` directory into your Home Assistant installation's `config/custom_components/` folder:
|
||||
```bash
|
||||
cp -R custom_components/nestquest <config_dir>/custom_components/
|
||||
```
|
||||
3. Restart Home Assistant.
|
||||
|
||||
## Configuration
|
||||
|
||||
After installation and restart:
|
||||
1. Navigate to **Settings** > **Devices & Services** in Home Assistant.
|
||||
2. Click **Add Integration** and search for **NestQuest**.
|
||||
3. Follow the on-screen configuration flow.
|
||||
|
||||
## Developer Setup and Testing
|
||||
|
||||
This project uses [`uv`](https://github.com/astral-sh/uv) for fast Python package and dependency management.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11 or newer
|
||||
- [uv](https://docs.astral.sh/uv/) installed on your machine
|
||||
|
||||
### Setup Environment
|
||||
|
||||
To set up the development environment and install dependencies:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Run the test suite with `pytest`:
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""The NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -11,6 +14,15 @@ from .const import DOMAIN, LOGGER
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
@dataclass
|
||||
class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
remove_update_listener: Callable[[], Any]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
||||
return True
|
||||
@@ -19,9 +31,37 @@ async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NestQuest from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
async def _async_update_listener(
|
||||
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Reload the entry when its options change."""
|
||||
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
||||
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
remove_update_listener=entry.add_update_listener(_async_update_listener),
|
||||
)
|
||||
entry.runtime_data = runtime_data
|
||||
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
||||
return True
|
||||
|
||||
|
||||
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)
|
||||
if runtime_data is None:
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
if runtime_data is not None:
|
||||
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
||||
if remove_update_listener is not None:
|
||||
result = remove_update_listener()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
if getattr(entry, "runtime_data", None) is not None:
|
||||
entry.runtime_data = None
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Config flow for NestQuest."""
|
||||
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
|
||||
|
||||
|
||||
class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for NestQuest."""
|
||||
|
||||
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:
|
||||
"""Handle the user step of the config flow."""
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
if self._async_in_progress() or self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
return self.async_create_entry(title="NestQuest", data=user_input)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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
|
||||
@@ -0,0 +1,621 @@
|
||||
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||
|
||||
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||
frozen dataclass with no Home Assistant and no database imports. The
|
||||
evaluation engine (Feature 04) consumes it; the DAO layer (Feature 02)
|
||||
serializes its dict form into the ``schedule_rules`` table.
|
||||
|
||||
Type model: one enum member per recurrence shape. ``MONTHLY_DAY``
|
||||
("the 15th of every month") and ``MONTHLY_WEEKDAY`` ("the second
|
||||
Tuesday of every month") are distinct types rather than one monthly
|
||||
type with optional fields, so validation is total: a rule either
|
||||
constructs, or raises :class:`RuleValidationError` naming the offending
|
||||
field. Every type carries ONLY the fields its shape needs; carrying
|
||||
any other shape's field is a construction error.
|
||||
|
||||
Storage mapping (Feature 02, ``schedule_rules`` table): the enum's
|
||||
``storage_value`` maps model types onto the schema's CHECK values —
|
||||
MONTHLY_DAY and MONTHLY_WEEKDAY both store as ``'monthly'`` and are
|
||||
disambiguated by which fields are populated; CUSTOM_DAYS stores as
|
||||
``'custom'``. ``weekday_set`` round-trips as the sorted CSV list the
|
||||
table stores (one element for MONTHLY_WEEKDAY, which names the
|
||||
position's weekday); ``nth_weekday_weekday`` is a MODEL-ONLY field
|
||||
carried through from_dict/to_dict for losslessness and folded into
|
||||
``weekday_set`` by the DAO mapping. ``to_dict``/``from_dict`` are the
|
||||
model-side lossless pair; the DB mapping lives in the DAO layer and is
|
||||
exercised there.
|
||||
|
||||
Validation policy (fail fast at construction — the schema's CHECK
|
||||
constraints are a second line of defense, but invalid rules must never
|
||||
reach storage):
|
||||
|
||||
- ``interval`` >= 1 for every rule.
|
||||
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||
elsewhere; entries are 0 (Monday) .. 6 (Sunday), no booleans.
|
||||
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||
- ``nth_weekday`` 1..5 or -1 (0 is meaningless as a position and is
|
||||
rejected) with a weekday 0..6 for MONTHLY_WEEKDAY.
|
||||
- ``month`` 1..12 is required for YEARLY.
|
||||
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||
when present, on or after ``start_date``.
|
||||
|
||||
Month-end policy (implemented in :func:`_occurs_monthly_day` and
|
||||
documented for Feature 04): in each interval-eligible month, a
|
||||
MONTHLY_DAY rule for a day that does not exist in that month — the
|
||||
31st in April, the 30th or 29th in February, February 29th in a
|
||||
non-leap year — fires on the LAST day of the month instead of being
|
||||
skipped. A monthly chore still happens in every eligible month,
|
||||
pinned to the month's end when its nominal date is absent; exactly one
|
||||
day per eligible month matches the rule (nominal day when it exists,
|
||||
month end when it does not). The interval anchors on nominal months
|
||||
elapsed from start_date's month, so clamping never shifts the cadence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
#: Weekday-name policy: 0 = Monday .. 6 = Sunday (ISO order).
|
||||
|
||||
|
||||
class RuleType(enum.Enum):
|
||||
"""The six recurrence shapes the engine supports.
|
||||
|
||||
``storage_value`` is the string written to the ``schedule_rules``
|
||||
table's ``rule_type`` column (its CHECK whitelist). MONTHLY_DAY
|
||||
and MONTHLY_WEEKDAY are distinct shapes sharing one storage value,
|
||||
disambiguated by which fields are populated.
|
||||
"""
|
||||
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY_DAY = "monthly_day"
|
||||
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||
YEARLY = "yearly"
|
||||
CUSTOM_DAYS = "custom_days"
|
||||
|
||||
@property
|
||||
def storage_value(self) -> str:
|
||||
"""The schema-level rule_type string this model type maps to."""
|
||||
return {
|
||||
RuleType.DAILY: "daily",
|
||||
RuleType.WEEKLY: "weekly",
|
||||
RuleType.MONTHLY_DAY: "monthly",
|
||||
RuleType.MONTHLY_WEEKDAY: "monthly",
|
||||
RuleType.YEARLY: "yearly",
|
||||
RuleType.CUSTOM_DAYS: "custom",
|
||||
}[self]
|
||||
|
||||
|
||||
class RuleValidationError(ValueError):
|
||||
"""Raised when a ScheduleRule field combination is invalid."""
|
||||
|
||||
|
||||
def _validate_date(value: str, field_name: str) -> None:
|
||||
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise RuleValidationError(
|
||||
f"{field_name} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_weekday_set(weekday_set) -> frozenset[int]:
|
||||
"""Return a normalized frozenset or raise with a clear message.
|
||||
|
||||
Sequence entries are validated BEFORE set() coercion: a list like
|
||||
[0, False] must not collapse the boolean into 0, and unhashable
|
||||
entries must raise RuleValidationError, never a raw TypeError.
|
||||
"""
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
for entry in weekday_set:
|
||||
# _is_plain_int: bool and int subclasses (potentially
|
||||
# unhashable) are rejected before set() coercion.
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
if not isinstance(weekday_set, (set, frozenset)):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set must be a set of weekday integers, got "
|
||||
f"{type(weekday_set).__name__}"
|
||||
)
|
||||
if not weekday_set:
|
||||
raise RuleValidationError(
|
||||
"weekday_set must not be empty for weekly/custom rules"
|
||||
)
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
if not 0 <= entry <= 6:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
return frozenset(weekday_set)
|
||||
|
||||
|
||||
def _is_plain_int(value) -> bool:
|
||||
"""True only for real ints: bool excluded AND int subclasses too
|
||||
(an unhashable int subclass would break the frozen-hashable model)."""
|
||||
return type(value) is int
|
||||
|
||||
|
||||
def _validate_monthly_weekday(nth_weekday, weekday) -> None:
|
||||
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||
if nth_weekday is None or not _is_plain_int(nth_weekday):
|
||||
raise RuleValidationError(
|
||||
"nth_weekday must be an integer for monthly_weekday rules, "
|
||||
f"got {nth_weekday!r}"
|
||||
)
|
||||
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||
raise RuleValidationError(
|
||||
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||
)
|
||||
if weekday is None or not _is_plain_int(weekday) or not 0 <= weekday <= 6:
|
||||
raise RuleValidationError(
|
||||
f"monthly_weekday rules require a weekday 0 (Monday)..6 "
|
||||
f"(Sunday), got {weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRule:
|
||||
"""An immutable, validated recurrence rule (see module docstring).
|
||||
|
||||
Construct with keyword arguments; invalid combinations raise
|
||||
:class:`RuleValidationError` at construction time. Fields map
|
||||
one-to-one onto the ``schedule_rules`` table columns, with
|
||||
``nth_weekday_weekday`` as the model-only weekday companion to
|
||||
``nth_weekday`` (serialized inside ``weekday_set`` at the DAO
|
||||
boundary).
|
||||
"""
|
||||
|
||||
rule_type: RuleType
|
||||
interval: int = 1
|
||||
weekday_set: frozenset[int] | None = None
|
||||
day_of_month: int | None = None
|
||||
nth_weekday: int | None = None
|
||||
nth_weekday_weekday: int | None = None
|
||||
month: int | None = None
|
||||
start_date: str = "1970-01-01"
|
||||
end_date: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Normalize a string rule_type into the enum; anything else
|
||||
# that is not already a RuleType is rejected.
|
||||
if isinstance(self.rule_type, str):
|
||||
# EXACT model names only: no whitespace stripping, no storage
|
||||
# aliases. The DAO maps storage strings to model fields when
|
||||
# reading rows; the model boundary stays unambiguous.
|
||||
candidates = {rt.name.lower(): rt for rt in RuleType}
|
||||
if self.rule_type in candidates:
|
||||
object.__setattr__(
|
||||
self, "rule_type", candidates[self.rule_type]
|
||||
)
|
||||
else:
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of "
|
||||
f"{sorted(candidates)}, got {self.rule_type!r}"
|
||||
)
|
||||
if not isinstance(self.rule_type, RuleType):
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be a RuleType or one of its string "
|
||||
f"values, got {self.rule_type!r}"
|
||||
)
|
||||
if not _is_plain_int(self.interval):
|
||||
raise RuleValidationError(
|
||||
f"interval must be an integer >= 1, got {self.interval!r}"
|
||||
)
|
||||
if self.interval < 1:
|
||||
raise RuleValidationError(
|
||||
f"interval must be >= 1, got {self.interval!r}"
|
||||
)
|
||||
|
||||
_validate_date(self.start_date, "start_date")
|
||||
if self.end_date is not None:
|
||||
_validate_date(self.end_date, "end_date")
|
||||
if self.end_date < self.start_date:
|
||||
raise RuleValidationError(
|
||||
f"end_date {self.end_date!r} must be on or after "
|
||||
f"start_date {self.start_date!r}"
|
||||
)
|
||||
|
||||
# Type-exclusive field policy, enforced for every type: build
|
||||
# the dict of present fields, then reject every field the shape
|
||||
# must not carry (weekday_set present is detectable via None vs
|
||||
# set even after normalization below).
|
||||
shape = self.rule_type
|
||||
if shape is RuleType.DAILY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.WEEKLY:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.CUSTOM_DAYS:
|
||||
forbidden_fields = (
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
forbidden_fields = ("weekday_set", "nth_weekday",
|
||||
"nth_weekday_weekday", "month")
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
forbidden_fields = ("weekday_set", "day_of_month", "month")
|
||||
elif shape is RuleType.YEARLY:
|
||||
forbidden_fields = (
|
||||
"weekday_set",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
)
|
||||
present_forbidden = [
|
||||
name
|
||||
for name in forbidden_fields
|
||||
if getattr(self, name) is not None
|
||||
]
|
||||
if present_forbidden:
|
||||
raise RuleValidationError(
|
||||
f"{shape.value} rules must not set "
|
||||
f"{', '.join(sorted(present_forbidden))}"
|
||||
)
|
||||
|
||||
# Required fields per shape.
|
||||
if shape is RuleType.WEEKLY or shape is RuleType.CUSTOM_DAYS:
|
||||
normalized = _validate_weekday_set(self.weekday_set)
|
||||
object.__setattr__(self, "weekday_set", normalized)
|
||||
elif shape is RuleType.MONTHLY_DAY:
|
||||
if self.day_of_month is None or not _is_plain_int(
|
||||
self.day_of_month
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules require day_of_month (1..31)"
|
||||
)
|
||||
if not 1 <= self.day_of_month <= 31:
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31, got {self.day_of_month!r}"
|
||||
)
|
||||
elif shape is RuleType.MONTHLY_WEEKDAY:
|
||||
_validate_monthly_weekday(
|
||||
self.nth_weekday, self.nth_weekday_weekday
|
||||
)
|
||||
elif shape is RuleType.YEARLY:
|
||||
if self.month is None or not _is_plain_int(self.month):
|
||||
raise RuleValidationError(
|
||||
"yearly rules require month (1..12)"
|
||||
)
|
||||
if not 1 <= self.month <= 12:
|
||||
raise RuleValidationError(
|
||||
f"month must be 1..12, got {self.month!r}"
|
||||
)
|
||||
if self.day_of_month is not None and not (
|
||||
_is_plain_int(self.day_of_month)
|
||||
and 1 <= self.day_of_month <= 31
|
||||
):
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31 for yearly rules, got "
|
||||
f"{self.day_of_month!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return the lossless dict form (model-side storage shape).
|
||||
|
||||
``weekday_set`` serializes as a sorted list; MONTHLY_WEEKDAY's
|
||||
weekday travels inside it (appended) plus
|
||||
``nth_weekday_weekday`` so the pair round-trips without
|
||||
ambiguity at the model layer.
|
||||
"""
|
||||
return {
|
||||
"rule_type": self.rule_type.name.lower(), # model name
|
||||
"interval": self.interval,
|
||||
"weekday_set": (
|
||||
sorted(self.weekday_set) if self.weekday_set is not None
|
||||
else None
|
||||
),
|
||||
"day_of_month": self.day_of_month,
|
||||
"nth_weekday": self.nth_weekday,
|
||||
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||
"month": self.month,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||
"""Rebuild a rule from :meth:`to_dict` output.
|
||||
|
||||
Raises :class:`RuleValidationError` on unknown keys, missing
|
||||
keys, or invalid combinations — never a raw TypeError.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise RuleValidationError(
|
||||
f"rule data must be a dict, got {type(data).__name__}"
|
||||
)
|
||||
allowed = {
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
}
|
||||
unknown = [key for key in data if key not in allowed]
|
||||
if unknown:
|
||||
raise RuleValidationError(
|
||||
f"unknown rule fields: {sorted(unknown, key=repr)}"
|
||||
)
|
||||
if "rule_type" not in data:
|
||||
raise RuleValidationError("missing required rule field: rule_type")
|
||||
weekday_set = data.get("weekday_set")
|
||||
if isinstance(weekday_set, (list, tuple)):
|
||||
# Validate entries as plain ints BEFORE set() coercion, so a
|
||||
# list like [0, False] cannot collapse False into 0 and an
|
||||
# unhashable/int-subclass entry cannot leak a raw TypeError.
|
||||
for entry in weekday_set:
|
||||
if not _is_plain_int(entry):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be plain integers, got "
|
||||
f"{entry!r}"
|
||||
)
|
||||
weekday_set = set(weekday_set)
|
||||
return cls(
|
||||
rule_type=data["rule_type"],
|
||||
interval=data.get("interval", 1),
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=data.get("day_of_month"),
|
||||
nth_weekday=data.get("nth_weekday"),
|
||||
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||
month=data.get("month"),
|
||||
start_date=data.get("start_date", "1970-01-01"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation engine (Feature 04): pure date arithmetic on ScheduleRule
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_date(value: str) -> datetime.date:
|
||||
"""Parse a strict ISO date already validated by the model."""
|
||||
return datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
|
||||
|
||||
def _anchor_offset(rule: ScheduleRule, target: datetime.date) -> int:
|
||||
"""Day offset from the rule's anchor date to ``target``.
|
||||
|
||||
The anchor for DAILY/WEEKLY-interval arithmetic is start_date: the
|
||||
first day the rule CAN fire. A negative offset means the target
|
||||
precedes the rule's window entirely.
|
||||
"""
|
||||
return (target - _parse_date(rule.start_date)).days
|
||||
|
||||
|
||||
def _within_window(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""True when ``target`` is inside the rule's [start, end] window."""
|
||||
if target < _parse_date(rule.start_date):
|
||||
return False
|
||||
if rule.end_date is not None and target > _parse_date(rule.end_date):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> int:
|
||||
"""The last calendar day of the given month (leap-aware)."""
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def _occurs_monthly_day(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""MONTHLY_DAY evaluation with the documented month-end policy.
|
||||
|
||||
Policy: in each interval-eligible month, a rule for a day-of-month
|
||||
that does not exist in that month (the 31st in April, the 30th in
|
||||
February, the 29th of February in a non-leap year) fires on the
|
||||
LAST day of that month instead of being skipped — the chore still
|
||||
happens that month, pinned to the month's end when its nominal date
|
||||
is absent.
|
||||
|
||||
The interval anchors on months ELAPSED from start_date's month: the
|
||||
rule fires when the months between the anchor month and the target's
|
||||
month form a multiple of the interval, keeping "every 2 months on
|
||||
the 15th" aligned to the anchor month even when the firing lands on
|
||||
a clamped month-end day.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def _nth_weekday_of_month(
|
||||
year: int, month: int, weekday: int, nth: int
|
||||
) -> int | None:
|
||||
"""The day-of-month of the ``nth`` ``weekday``, or None when the
|
||||
month has no such occurrence (``nth`` 5 beyond four occurrences).
|
||||
|
||||
Positive ``nth`` counts forward (1 = first); ``-1`` counts from the
|
||||
end (last occurrence, whatever the total). Pure calendar math.
|
||||
"""
|
||||
if nth == -1:
|
||||
last = datetime.date(year, month, _month_end(year, month))
|
||||
back = (last.weekday() - weekday) % 7
|
||||
return last.day - back
|
||||
first = datetime.date(year, month, 1)
|
||||
first_offset = (weekday - first.weekday()) % 7
|
||||
day = 1 + first_offset + (nth - 1) * 7
|
||||
if day > _month_end(year, month):
|
||||
return None
|
||||
return day
|
||||
|
||||
|
||||
def _occurs_monthly_weekday(
|
||||
rule: ScheduleRule, target: datetime.date
|
||||
) -> bool:
|
||||
"""MONTHLY_WEEKDAY evaluation.
|
||||
|
||||
Fires when the target is the ``nth`` occurrence of
|
||||
``nth_weekday_weekday`` in its month (1..4 forward, -1 last). A
|
||||
month with fewer occurrences than ``nth`` (5 on a four-occurrence
|
||||
month) does not fire — n 5 is accepted by the model but skips such
|
||||
months, matching the done-condition. The interval anchors on
|
||||
nominal months elapsed from start_date's month.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
months_elapsed = (
|
||||
(target.year - anchor.year) * 12 + (target.month - anchor.month)
|
||||
)
|
||||
if months_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.weekday() != rule.nth_weekday_weekday:
|
||||
return False
|
||||
nth_day = _nth_weekday_of_month(
|
||||
target.year, target.month, rule.nth_weekday_weekday, rule.nth_weekday
|
||||
)
|
||||
return nth_day is not None and target.day == nth_day
|
||||
|
||||
|
||||
def _occurs_yearly(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""YEARLY evaluation with the documented leap-day clamp policy.
|
||||
|
||||
Policy: in each interval-eligible year, the rule fires on
|
||||
``day_of_month`` of ``month`` when that day exists. A YEARLY rule
|
||||
for February 29th fires on FEBRUARY 28TH in a non-leap year (the
|
||||
same clamp-as-month-end policy as MONTHLY_DAY: the annual chore
|
||||
still happens every year, pinned to February's end when its
|
||||
nominal date is absent) rather than silently vanishing. A YEARLY
|
||||
rule with NO day_of_month fires on the first day of ``month``.
|
||||
The interval anchors on years elapsed from start_date's year.
|
||||
"""
|
||||
anchor = _parse_date(rule.start_date)
|
||||
years_elapsed = target.year - anchor.year
|
||||
if years_elapsed % rule.interval != 0:
|
||||
return False
|
||||
if target.month != rule.month:
|
||||
return False
|
||||
if rule.day_of_month is None:
|
||||
# Day-less yearly rules fire on the first of the month.
|
||||
return target.day == 1
|
||||
month_end = _month_end(target.year, target.month)
|
||||
return target.day == min(rule.day_of_month, month_end)
|
||||
|
||||
|
||||
def occurs_on(rule: ScheduleRule, target: datetime.date) -> bool:
|
||||
"""Return True when ``rule`` fires on ``target``.
|
||||
|
||||
Pure function: no I/O, no timezone awareness, no mutation. Dates
|
||||
are plain calendar dates (Feature 04 guardrail); the caller decides
|
||||
the timezone that produced them.
|
||||
"""
|
||||
# Dispatch FIRST: an unimplemented shape must raise loudly even for
|
||||
# dates outside the window, never silently evaluate to False.
|
||||
shape = rule.rule_type
|
||||
if shape not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurs_on for {shape} lands with its own engine task "
|
||||
"(Features 04 task sequence)"
|
||||
)
|
||||
if not _within_window(rule, target):
|
||||
return False
|
||||
if shape is RuleType.DAILY:
|
||||
offset = (target - _parse_date(rule.start_date)).days
|
||||
return offset % rule.interval == 0
|
||||
if shape is RuleType.MONTHLY_DAY:
|
||||
return _occurs_monthly_day(rule, target)
|
||||
if shape is RuleType.MONTHLY_WEEKDAY:
|
||||
return _occurs_monthly_weekday(rule, target)
|
||||
if shape is RuleType.YEARLY:
|
||||
return _occurs_yearly(rule, target)
|
||||
# WEEKLY / CUSTOM_DAYS: whole-week anchoring from start_date, never
|
||||
# ISO week numbers (Feature 04/05 guardrail: 53-week years flip ISO
|
||||
# parity on January 1st; anchor arithmetic does not).
|
||||
anchor = _parse_date(rule.start_date)
|
||||
if shape is RuleType.CUSTOM_DAYS:
|
||||
interval = 1
|
||||
else:
|
||||
interval = rule.interval
|
||||
week_offset = (target - anchor).days // 7
|
||||
if week_offset % interval != 0:
|
||||
return False
|
||||
return target.weekday() in rule.weekday_set
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
rule: ScheduleRule, start: str, end: str
|
||||
) -> list[str]:
|
||||
"""Return every date string in [start, end] the rule fires on.
|
||||
|
||||
Inclusive on both ends. Raises RuleValidationError on malformed or
|
||||
inverted bounds; a window entirely outside [rule.start_date,
|
||||
rule.end_date] yields an empty list.
|
||||
"""
|
||||
if rule.rule_type not in (
|
||||
RuleType.DAILY, RuleType.WEEKLY, RuleType.CUSTOM_DAYS,
|
||||
RuleType.MONTHLY_DAY, RuleType.MONTHLY_WEEKDAY, RuleType.YEARLY,
|
||||
):
|
||||
raise NotImplementedError(
|
||||
f"occurrences_between for {rule.rule_type} lands with its own "
|
||||
"engine task (Features 04 task sequence)"
|
||||
)
|
||||
_validate_date(start, "start")
|
||||
_validate_date(end, "end")
|
||||
start_date = _parse_date(start)
|
||||
end_date = _parse_date(end)
|
||||
if end_date < start_date:
|
||||
raise RuleValidationError(
|
||||
f"end {end!r} must be on or after start {start!r}"
|
||||
)
|
||||
rule_start = _parse_date(rule.start_date)
|
||||
if end_date < rule_start:
|
||||
return []
|
||||
# Clamp the walk to the rule's own window and the queried range.
|
||||
effective_start = max(start_date, rule_start)
|
||||
effective_end = end_date
|
||||
if rule.end_date is not None:
|
||||
effective_end = min(effective_end, _parse_date(rule.end_date))
|
||||
results: list[str] = []
|
||||
cursor = effective_start
|
||||
while cursor <= effective_end:
|
||||
if occurs_on(rule, cursor):
|
||||
results.append(cursor.isoformat())
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return results
|
||||
@@ -0,0 +1,20 @@
|
||||
"""SQLite storage path resolution for the NestQuest integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create the given directory and any missing parents."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def async_get_db_path(hass: HomeAssistant) -> Path:
|
||||
"""Return the absolute path to the NestQuest SQLite database file."""
|
||||
db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute()
|
||||
await hass.async_add_executor_job(_ensure_dir, db_path.parent)
|
||||
return db_path
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "NestQuest",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add NestQuest",
|
||||
"description": "Set up NestQuest to start coordinating the household. No additional configuration is required."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "NestQuest is already configured. Only a single instance is allowed."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "NestQuest",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add NestQuest",
|
||||
"description": "Set up NestQuest to start coordinating the household. No additional configuration is required."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "NestQuest is already configured. Only a single instance is allowed."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "NestQuest",
|
||||
"homeassistant": "2024.6.0",
|
||||
"render_readme": true
|
||||
}
|
||||
@@ -9,8 +9,11 @@ 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"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the config flow of NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest import config_flow
|
||||
from custom_components.nestquest.const import DOMAIN
|
||||
|
||||
PKG_DIR = Path(config_flow.__file__).parent
|
||||
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:
|
||||
"""The initial user step shows a form."""
|
||||
flow = make_flow(hass)
|
||||
result = await 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({})
|
||||
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:
|
||||
"""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)
|
||||
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:
|
||||
"""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)
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "single_instance_allowed"
|
||||
|
||||
|
||||
async def test_flow_sets_stable_unique_id(hass, make_flow) -> None:
|
||||
"""The flow assigns a stable unique id equal to the domain."""
|
||||
flow = make_flow(hass)
|
||||
assert flow.context.get("unique_id") is None
|
||||
await 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:
|
||||
"""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)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
|
||||
def test_flow_strings_valid_json(fixture_name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""strings.json and translations/en.json are valid JSON objects."""
|
||||
content = request.getfixturevalue(fixture_name)
|
||||
assert isinstance(content, dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
|
||||
def test_flow_strings_structure(fixture_name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""Config flow strings contain title, user step, and abort reasons."""
|
||||
content = request.getfixturevalue(fixture_name)
|
||||
assert content.get("title")
|
||||
config = content.get("config", {})
|
||||
assert config.get("step", {}).get("user", {}).get("title")
|
||||
assert config.get("step", {}).get("user", {}).get("description")
|
||||
abort = config.get("abort", {})
|
||||
assert "single_instance_allowed" in abort
|
||||
assert abort["single_instance_allowed"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
|
||||
def test_flow_strings_no_raw_keys(fixture_name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""No placeholder/raw keys appear in the flow strings."""
|
||||
content = request.getfixturevalue(fixture_name)
|
||||
|
||||
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(content)
|
||||
|
||||
|
||||
def test_flow_strings_match_translations(
|
||||
strings: dict, translations_en: dict
|
||||
) -> None:
|
||||
"""strings.json and translations/en.json carry identical flow strings."""
|
||||
assert strings == translations_en
|
||||
|
||||
|
||||
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
|
||||
"""Ensure no module other than const.py hardcodes the domain or db filename."""
|
||||
pkg_dir = Path(config_flow.__file__).parent
|
||||
py_files = [f for f in pkg_dir.glob("*.py") if f.name not in ("const.py",)]
|
||||
|
||||
assert len(py_files) > 0, "No python modules found in package to check"
|
||||
|
||||
for py_file in py_files:
|
||||
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):
|
||||
if node.value == "nestquest":
|
||||
raise AssertionError(
|
||||
f"Found hard-coded domain string in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
assert node.value != "nestquest.db", (
|
||||
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
@@ -5,12 +5,6 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests verifying HACS repository metadata, documentation, and licensing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
HACS_JSON_PATH = REPO_ROOT / "hacs.json"
|
||||
LICENSE_PATH = REPO_ROOT / "LICENSE"
|
||||
README_PATH = REPO_ROOT / "README.md"
|
||||
MANIFEST_PATH = REPO_ROOT / "custom_components" / "nestquest" / "manifest.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hacs_config() -> dict:
|
||||
"""Load and parse hacs.json."""
|
||||
assert HACS_JSON_PATH.exists(), f"hacs.json does not exist at {HACS_JSON_PATH}"
|
||||
with open(HACS_JSON_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data
|
||||
|
||||
|
||||
def test_hacs_json_exists_and_is_valid(hacs_config: dict) -> None:
|
||||
"""Verify hacs.json exists and is valid JSON."""
|
||||
assert isinstance(hacs_config, dict)
|
||||
assert hacs_config.get("name") == "NestQuest"
|
||||
assert hacs_config.get("homeassistant") == "2024.6.0"
|
||||
assert hacs_config.get("render_readme") is True
|
||||
|
||||
|
||||
def test_hacs_json_minimum_version_supports_runtime_data(hacs_config: dict) -> None:
|
||||
"""Advertised HA floor must support ConfigEntry.runtime_data (added in 2024.6)."""
|
||||
advertised = tuple(int(part) for part in hacs_config.get("homeassistant", "").split("."))
|
||||
assert advertised >= (2024, 6, 0)
|
||||
|
||||
|
||||
def test_license_exists_and_non_empty() -> None:
|
||||
"""Verify LICENSE file exists and is non-empty."""
|
||||
assert LICENSE_PATH.exists(), f"LICENSE file does not exist at {LICENSE_PATH}"
|
||||
content = LICENSE_PATH.read_text(encoding="utf-8").strip()
|
||||
assert len(content) > 0
|
||||
assert "MIT License" in content
|
||||
assert "NestQuest Contributors / Maniacs Mansion" in content
|
||||
|
||||
|
||||
def test_readme_exists_and_contains_required_sections() -> None:
|
||||
"""Verify README.md exists and documents HACS installation, manual installation, and test commands."""
|
||||
assert README_PATH.exists(), f"README.md does not exist at {README_PATH}"
|
||||
content = README_PATH.read_text(encoding="utf-8")
|
||||
assert len(content.strip()) > 0
|
||||
|
||||
# Title and overview
|
||||
assert "NestQuest - House Chaos Coordination" in content
|
||||
assert "Home Assistant custom integration" in content
|
||||
|
||||
# Installation methods
|
||||
assert "HACS" in content
|
||||
assert "Manual Installation" in content or "manual installation" in content.lower()
|
||||
assert "custom_components/nestquest" in content
|
||||
|
||||
# Developer Setup and Testing commands
|
||||
assert "uv sync" in content
|
||||
assert "uv run pytest" in content
|
||||
|
||||
|
||||
def test_readme_hacs_points_at_public_github_mirror() -> None:
|
||||
"""Verify README directs HACS users to the public GitHub mirror, not the private Gitea repo."""
|
||||
content = README_PATH.read_text(encoding="utf-8")
|
||||
assert "https://github.com/talon2king/NestQuest" in content
|
||||
assert "https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest" in content
|
||||
assert content.index("https://github.com/talon2king/NestQuest") < content.index(
|
||||
"https://code.cubecraftlabs.com/Maniacs_Mansion/NestQuest"
|
||||
)
|
||||
|
||||
|
||||
def test_hacs_directory_structure() -> None:
|
||||
"""Verify directory structure matches HACS integration expectations."""
|
||||
assert MANIFEST_PATH.exists(), f"Integration manifest does not exist at {MANIFEST_PATH}"
|
||||
with open(MANIFEST_PATH, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest.get("domain") == "nestquest"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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")]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for the setup/unload lifecycle of NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from conftest import make_config_entry, make_hass, wire_entry_to_registry
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 == {}
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
assert registry.size == 1
|
||||
await 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)
|
||||
entry.options = {"horizon_days": 30}
|
||||
await registry.async_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)
|
||||
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)
|
||||
for _ in range(3):
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert registry.size == 1
|
||||
assert await 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:
|
||||
"""Listeners accumulate per entry (HA semantics); unload must remove them all."""
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await 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:
|
||||
"""After each setup, an options update dispatches to exactly one listener."""
|
||||
registry = hass.registry
|
||||
entry = _wire(make_entry(), registry)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await registry.async_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)
|
||||
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)
|
||||
for _ in range(3):
|
||||
await async_setup_entry(hass, entry)
|
||||
await registry.async_dispatch_options_update(entry)
|
||||
await 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 == []
|
||||
|
||||
|
||||
async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
await async_setup_entry(hass, entry)
|
||||
assert set(hass.data) == {DOMAIN_CONST}
|
||||
|
||||
|
||||
async def test_setup_awaitable_listener_removal_supported() -> None:
|
||||
"""A coroutine-returning remover is awaited during unload."""
|
||||
removed = []
|
||||
|
||||
async def _remover():
|
||||
removed.append(True)
|
||||
|
||||
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
|
||||
@@ -3,12 +3,6 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""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}"
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Window-bound enforcement across every rule type (start_date/end_date).
|
||||
|
||||
Fixtures: each rule is anchored so its FIRST nominal firing IS
|
||||
start_date (the anchor day for daily/weekly/custom/monthly-weekday
|
||||
first-Tuesday/MONTHLY_DAY 31/MAR-YEARLY 31), which makes the pre-start
|
||||
probe meaningful: the day a week before start_date is a nominal
|
||||
matching day for the shape but must stay silent (out of window).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
def _tuesday(year: int, month: int, nth: int) -> datetime.date:
|
||||
"""The nth Tuesday of the month (nth >= 1)."""
|
||||
day = datetime.date(year, month, 1)
|
||||
while day.weekday() != 1:
|
||||
day += datetime.timedelta(days=1)
|
||||
return day + datetime.timedelta(weeks=nth - 1)
|
||||
|
||||
|
||||
def _friday(year: int, month: int, nth: int) -> datetime.date:
|
||||
day = datetime.date(year, month, 1)
|
||||
while day.weekday() != 4:
|
||||
day += datetime.timedelta(days=1)
|
||||
return day + datetime.timedelta(weeks=nth - 1)
|
||||
|
||||
|
||||
def _month_end(year: int, month: int) -> datetime.date:
|
||||
if month == 12:
|
||||
return datetime.date(year, 12, 31)
|
||||
return datetime.date(year, month + 1, 1) - datetime.timedelta(days=1)
|
||||
|
||||
|
||||
#: One representative rule per type. Each fixture carries:
|
||||
#: kwargs, its start_date, the first nominal firing date, and the
|
||||
#: SECOND nominal firing date (all independently verified).
|
||||
#: - DAILY: start Mar 1; fires daily; second firing Mar 2.
|
||||
#: - WEEKLY (Tuesdays): start Tue Mar 3; second firing Tue Mar 10.
|
||||
#: - CUSTOM_DAYS (Wed + Fri): start Wed Mar 4; second firing Fri Mar 6.
|
||||
#: - MONTHLY_DAY (31st): start Tue Mar 31; second firing Apr 30 (clamp).
|
||||
#: - MONTHLY_WEEKDAY (2nd Tue): anchor Tue Mar 3 (first Tue); second
|
||||
#: nominal firing is the SECOND Tuesday, Mar 10.
|
||||
#: - YEARLY (Mar 31): start Tue Mar 31; second firing Mar 31 2027.
|
||||
BOUNDARY_RULES = [
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.DAILY,
|
||||
"start_date": "2026-03-01"},
|
||||
"first_firing": _d("2026-03-01"),
|
||||
"second_firing": _d("2026-03-02"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.WEEKLY, "weekday_set": {1},
|
||||
"start_date": "2026-03-03"},
|
||||
"first_firing": _d("2026-03-03"),
|
||||
"second_firing": _d("2026-03-10"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.CUSTOM_DAYS,
|
||||
"weekday_set": {2, 4}, "start_date": "2026-03-04"},
|
||||
"first_firing": _d("2026-03-04"),
|
||||
"second_firing": _d("2026-03-06"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.MONTHLY_DAY,
|
||||
"day_of_month": 31, "start_date": "2026-03-31"},
|
||||
"first_firing": _d("2026-03-31"),
|
||||
"second_firing": _d("2026-04-30"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.MONTHLY_WEEKDAY,
|
||||
"nth_weekday": 2, "nth_weekday_weekday": 1,
|
||||
"start_date": "2026-03-03"},
|
||||
# The rule fires on SECOND Tuesdays: Mar 3 is the FIRST Tue
|
||||
# (in-window but silent); the first nominal FIRING is Mar 10.
|
||||
"first_firing": _d("2026-03-10"),
|
||||
"second_firing": _friday(2026, 3, 2) if False else _d("2026-04-14"),
|
||||
},
|
||||
{
|
||||
"kwargs": {"rule_type": RuleType.YEARLY, "month": 3,
|
||||
"day_of_month": 31, "start_date": "2026-03-31"},
|
||||
"first_firing": _d("2026-03-31"),
|
||||
"second_firing": _d("2027-03-31"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_date bound: nothing fires before it, for EVERY rule type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
|
||||
def test_no_firing_before_start_date(index) -> None:
|
||||
fixture = BOUNDARY_RULES[index]
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
before_start = _d(rule.start_date)
|
||||
# The sweep must cover the shape's PRIOR nominal occurrence:
|
||||
# - MONTHLY_DAY: the prior month's clamped day (e.g. Feb 28/31)
|
||||
# - YEARLY: the prior year's nominal day
|
||||
# - WEEKLY/CUSTOM_DAYS: the prior week's matching weekday
|
||||
# - MONTHLY_WEEKDAY: the prior month's nth weekday
|
||||
if rule.rule_type is RuleType.MONTHLY_DAY:
|
||||
sweep_from = _month_end(2026, 2) # Feb 28, a nominal clamp day
|
||||
elif rule.rule_type is RuleType.YEARLY:
|
||||
sweep_from = datetime.date(
|
||||
_d(rule.start_date).year - 1, rule.month, rule.day_of_month
|
||||
)
|
||||
elif rule.rule_type is RuleType.MONTHLY_WEEKDAY:
|
||||
sweep_from = _tuesday(2026, 2, 2) # prior month's 2nd Tuesday
|
||||
elif rule.rule_type is RuleType.WEEKLY:
|
||||
sweep_from = before_start - datetime.timedelta(days=7)
|
||||
elif rule.rule_type is RuleType.CUSTOM_DAYS:
|
||||
sweep_from = before_start - datetime.timedelta(days=7)
|
||||
else:
|
||||
sweep_from = before_start - datetime.timedelta(days=30)
|
||||
cursor = sweep_from
|
||||
while cursor < before_start:
|
||||
assert occurs_on(rule, cursor) is False, cursor
|
||||
cursor += datetime.timedelta(days=1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index", range(len(BOUNDARY_RULES)))
|
||||
def test_first_nominal_firing_is_on_or_after_start(index) -> None:
|
||||
"""The first nominal firing must actually fire: the window must not
|
||||
exclude a date at or after start_date."""
|
||||
fixture = BOUNDARY_RULES[index]
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
first = fixture["first_firing"]
|
||||
assert occurs_on(rule, first) is True, first
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end_date bound: nothing fires after it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_firing_after_end_date_all_types() -> None:
|
||||
window_end = _d("2026-09-30")
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-09-30", **fixture["kwargs"]
|
||||
)
|
||||
# Sweep every day in the week after window_end: none may fire,
|
||||
# regardless of whether that date would nominally match.
|
||||
for offset in range(1, 8):
|
||||
after = window_end + datetime.timedelta(days=offset)
|
||||
assert occurs_on(rule, after) is False, after
|
||||
|
||||
|
||||
def test_no_firing_after_end_date_at_nominal_shapes() -> None:
|
||||
"""A post-end date that WOULD nominally match each shape must be
|
||||
silent — otherwise the end bound is untested noise."""
|
||||
rule_monthly = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-03-31", end_date="2026-04-30",
|
||||
)
|
||||
# Apr 30 is INSIDE the window (fires: clamp); May 31 would nominally
|
||||
# match but is after end_date: silent.
|
||||
assert occurs_on(rule_monthly, _d("2026-04-30")) is True
|
||||
assert occurs_on(rule_monthly, _d("2026-05-31")) is False
|
||||
|
||||
rule_yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
|
||||
start_date="2026-03-31", end_date="2027-03-31",
|
||||
)
|
||||
# Mar 31 2027 is inside; Mar 31 2028 would match but is past end.
|
||||
assert occurs_on(rule_yearly, _d("2027-03-31")) is True
|
||||
assert occurs_on(rule_yearly, _d("2028-03-31")) is False
|
||||
|
||||
rule_weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1},
|
||||
start_date="2026-03-03", end_date="2026-10-05",
|
||||
)
|
||||
# Tue Oct 6 would nominally match but is after end_date (Oct 5 is a
|
||||
# Monday, not in the set).
|
||||
assert occurs_on(rule_weekly, _d("2026-09-29")) is True
|
||||
assert occurs_on(rule_weekly, _d("2026-10-06")) is False
|
||||
|
||||
rule_monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-03-03",
|
||||
end_date="2026-10-05",
|
||||
)
|
||||
# The next nominal second Tuesday (Oct 13) is past end_date: silent.
|
||||
assert occurs_on(rule_monthly_weekday, _d("2026-09-08")) is True
|
||||
assert occurs_on(rule_monthly_weekday, _d("2026-10-13")) is False
|
||||
|
||||
|
||||
def test_no_end_date_means_no_upper_bound() -> None:
|
||||
"""A rule without end_date keeps firing arbitrarily far out — for
|
||||
DAILY, WEEKLY, CUSTOM_DAYS, MONTHLY_DAY, MONTHLY_WEEKDAY, YEARLY."""
|
||||
daily = ScheduleRule(rule_type=RuleType.DAILY,
|
||||
start_date="2026-03-01")
|
||||
assert occurs_on(daily, _d("2036-03-01")) is True
|
||||
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1},
|
||||
start_date="2026-03-03",
|
||||
)
|
||||
# 2076-03-03 is 50 years to the day after the anchor and a Tuesday.
|
||||
assert occurs_on(weekly, _d("2076-03-03")) is True
|
||||
|
||||
custom = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={4},
|
||||
start_date="2026-03-06", # a Friday
|
||||
)
|
||||
assert occurs_on(custom, _d("2026-03-06")) is True
|
||||
# The first Friday of March 2046 is Mar 2; Mar 6 is a Tuesday.
|
||||
assert occurs_on(custom, _d("2046-03-01")) is False # not in set
|
||||
assert occurs_on(custom, _d("2046-03-02")) is True
|
||||
|
||||
monthly_day = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(monthly_day, datetime.date(2036, 3, 15)) is True
|
||||
|
||||
monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, start_date="2026-03-04",
|
||||
)
|
||||
# First Wednesday of March 2036.
|
||||
d = datetime.date(2036, 3, 1)
|
||||
while d.weekday() != 2:
|
||||
d += datetime.timedelta(days=1)
|
||||
assert occurs_on(monthly_weekday, d) is True
|
||||
|
||||
yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=31,
|
||||
start_date="2026-03-31",
|
||||
)
|
||||
assert occurs_on(yearly, datetime.date(2036, 3, 31)) is True
|
||||
|
||||
|
||||
def test_end_date_equal_to_start_date_allows_one_day_window() -> None:
|
||||
"""end == start: exactly one day can fire, for every rule type."""
|
||||
daily = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-03-15",
|
||||
end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(daily, _d("2026-03-15")) is True
|
||||
assert occurs_on(daily, _d("2026-03-14")) is False
|
||||
assert occurs_on(daily, _d("2026-03-16")) is False
|
||||
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1}, # 2026-03-10 is a Tue
|
||||
start_date="2026-03-10", end_date="2026-03-10",
|
||||
)
|
||||
# The window is a SINGLE day (end == start, a Tuesday in-set):
|
||||
# it fires, and the following Tuesday is out of window.
|
||||
assert _d("2026-03-10").weekday() == 1
|
||||
assert occurs_on(weekly, _d("2026-03-10")) is True
|
||||
assert occurs_on(weekly, _d("2026-03-17")) is False
|
||||
|
||||
custom = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={2, 4},
|
||||
start_date="2026-03-11", end_date="2026-03-11",
|
||||
)
|
||||
# Mar 11 2026 is a Wednesday: fires. Next Wed (18) is out of window.
|
||||
assert _d("2026-03-11").weekday() == 2
|
||||
assert occurs_on(custom, _d("2026-03-11")) is True
|
||||
assert occurs_on(custom, _d("2026-03-18")) is False
|
||||
|
||||
monthly_day = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15", end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(monthly_day, _d("2026-03-15")) is True
|
||||
|
||||
monthly_weekday = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-03-10",
|
||||
end_date="2026-03-10",
|
||||
)
|
||||
# Mar 10 2026 is the second Tuesday: the one-day window admits it.
|
||||
assert _d("2026-03-10").weekday() == 1
|
||||
assert occurs_on(monthly_weekday, _d("2026-03-10")) is True
|
||||
assert occurs_on(monthly_weekday, _d("2026-04-14")) is False
|
||||
|
||||
yearly = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=15,
|
||||
start_date="2026-03-15", end_date="2026-03-15",
|
||||
)
|
||||
assert occurs_on(yearly, _d("2026-03-15")) is True
|
||||
assert occurs_on(yearly, _d("2027-03-15")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# end_date before start_date is rejected at construction, every type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"rule_type": RuleType.DAILY, "start_date": "2026-09-01",
|
||||
"end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.WEEKLY, "weekday_set": {0},
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.CUSTOM_DAYS, "weekday_set": {0},
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.MONTHLY_DAY, "day_of_month": 15,
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.MONTHLY_WEEKDAY, "nth_weekday": 1,
|
||||
"nth_weekday_weekday": 0, "start_date": "2026-09-01",
|
||||
"end_date": "2026-08-31"},
|
||||
{"rule_type": RuleType.YEARLY, "month": 6, "day_of_month": 15,
|
||||
"start_date": "2026-09-01", "end_date": "2026-08-31"},
|
||||
],
|
||||
)
|
||||
def test_end_before_start_rejected_for_every_type(kwargs) -> None:
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
ScheduleRule(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between respects the window for every type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_occurrences_between_respects_end_bound_all_types() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-06-30", **fixture["kwargs"]
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-12-31")
|
||||
# Every result inside the queried range, none beyond June.
|
||||
assert all(date <= "2026-06-30" for date in result), result
|
||||
assert all(
|
||||
date >= fixture["kwargs"]["start_date"] for date in result
|
||||
), result
|
||||
|
||||
|
||||
def test_occurrences_between_empty_when_range_before_start() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(**fixture["kwargs"])
|
||||
assert occurrences_between(rule, "2026-01-01", "2026-02-28") == []
|
||||
|
||||
|
||||
def test_occurrences_between_empty_when_range_after_end() -> None:
|
||||
for fixture in BOUNDARY_RULES:
|
||||
rule = ScheduleRule(
|
||||
end_date="2026-06-30", **fixture["kwargs"],
|
||||
)
|
||||
result = occurrences_between(rule, "2026-10-01", "2026-12-31")
|
||||
assert result == []
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Tests for the daily recurrence evaluation (occurs_on/occurrences_between)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
def test_daily_fires_every_day_from_start() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
assert occurs_on(rule, _d("2026-09-01")) is True
|
||||
assert occurs_on(rule, _d("2026-09-02")) is True
|
||||
assert occurs_on(rule, _d("2026-09-30")) is True
|
||||
|
||||
|
||||
def test_daily_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
assert occurs_on(rule, _d("2026-08-31")) is False
|
||||
|
||||
|
||||
def test_daily_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-09-01",
|
||||
end_date="2026-09-10",
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-10")) is True
|
||||
assert occurs_on(rule, _d("2026-09-11")) is False
|
||||
|
||||
|
||||
def test_daily_interval_2_fires_every_second_day() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
|
||||
)
|
||||
# Odd offsets fire (0, 2, 4...); odd offsets don't.
|
||||
assert occurs_on(rule, _d("2026-09-01")) is True # offset 0
|
||||
assert occurs_on(rule, _d("2026-09-02")) is False # offset 1
|
||||
assert occurs_on(rule, _d("2026-09-03")) is True # offset 2
|
||||
assert occurs_on(rule, _d("2026-09-04")) is False # offset 3
|
||||
assert occurs_on(rule, _d("2026-09-05")) is True # offset 4
|
||||
|
||||
|
||||
def test_daily_interval_3_fires_every_third_day() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-01")) is True # 0
|
||||
assert occurs_on(rule, _d("2026-09-02")) is False # 1
|
||||
assert occurs_on(rule, _d("2026-09-03")) is False # 2
|
||||
assert occurs_on(rule, _d("2026-09-04")) is True # 3
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True # 6
|
||||
|
||||
|
||||
def test_hand_checked_30_day_sequence_interval_1() -> None:
|
||||
"""The done-condition's hand-checked sequence: 30 days, all fire."""
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
expected = [
|
||||
(2026, 9, day) for day in range(1, 31)
|
||||
]
|
||||
for (year, month, day) in expected:
|
||||
assert occurs_on(rule, datetime.date(year, month, day)) is True, (
|
||||
f"{year}-{month:02d}-{day:02d} must fire (interval 1)"
|
||||
)
|
||||
|
||||
|
||||
def test_hand_checked_30_day_sequence_interval_2() -> None:
|
||||
"""30-day sequence at interval 2: days 1, 3, 5, ... (odd days)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
|
||||
)
|
||||
for day in range(1, 31):
|
||||
fired = occurs_on(rule, datetime.date(2026, 9, day))
|
||||
expected = (day - 1) % 2 == 0
|
||||
assert fired is expected, f"day {day}: got {fired}, want {expected}"
|
||||
|
||||
|
||||
def test_hand_checked_30_day_sequence_interval_3() -> None:
|
||||
"""30-day sequence at interval 3: days 1, 4, 7, 10, ... 28."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01"
|
||||
)
|
||||
fired_days = [
|
||||
day
|
||||
for day in range(1, 31)
|
||||
if occurs_on(rule, datetime.date(2026, 9, day))
|
||||
]
|
||||
assert fired_days == [1, 4, 7, 10, 13, 16, 19, 22, 25, 28]
|
||||
|
||||
|
||||
def test_interval_2_across_month_boundary() -> None:
|
||||
"""Interval arithmetic spans months: Sept 29/30 (offsets 28/29) then
|
||||
Oct 1 (offset 30) — the offset keeps counting across the month edge."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-29")) is True # offset 28
|
||||
assert occurs_on(rule, _d("2026-09-30")) is False # offset 29
|
||||
assert occurs_on(rule, _d("2026-10-01")) is True # offset 30
|
||||
assert occurs_on(rule, _d("2026-10-02")) is False # offset 31
|
||||
|
||||
|
||||
def test_interval_2_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-12-30")) is True # 0
|
||||
assert occurs_on(rule, _d("2026-12-31")) is False # 1
|
||||
assert occurs_on(rule, _d("2027-01-01")) is True # 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_occurrences_between_daily_interval_1() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
result = occurrences_between(rule, "2026-09-05", "2026-09-08")
|
||||
assert result == [
|
||||
"2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08",
|
||||
]
|
||||
|
||||
|
||||
def test_occurrences_between_daily_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
|
||||
)
|
||||
result = occurrences_between(rule, "2026-09-01", "2026-09-10")
|
||||
assert result == [
|
||||
"2026-09-01", "2026-09-03", "2026-09-05", "2026-09-07", "2026-09-09",
|
||||
]
|
||||
|
||||
|
||||
def test_occurrences_between_clamps_to_rule_start() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-05")
|
||||
result = occurrences_between(rule, "2026-09-01", "2026-09-08")
|
||||
assert result == ["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"]
|
||||
|
||||
|
||||
def test_occurrences_between_clamps_to_rule_end() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-09-01",
|
||||
end_date="2026-09-06",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-09-01", "2026-09-20")
|
||||
assert result == [
|
||||
f"2026-09-{day:02d}" for day in range(1, 7)
|
||||
]
|
||||
|
||||
|
||||
def test_occurrences_between_disjoint_range_is_empty() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-09-01",
|
||||
end_date="2026-09-15",
|
||||
)
|
||||
# The rule ends Sept 15: October is entirely outside its window.
|
||||
assert occurrences_between(rule, "2026-10-01", "2026-10-05") == []
|
||||
assert occurrences_between(rule, "2026-08-01", "2026-08-05") == []
|
||||
|
||||
|
||||
def test_occurrences_between_inclusive_bounds() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01"
|
||||
)
|
||||
# Both boundary dates fire at interval 2 (offsets 0 and 6).
|
||||
result = occurrences_between(rule, "2026-09-01", "2026-09-07")
|
||||
assert result[0] == "2026-09-01"
|
||||
assert result[-1] == "2026-09-07"
|
||||
|
||||
|
||||
def test_occurrences_between_rejects_inverted_range() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-09-10", "2026-09-05")
|
||||
|
||||
|
||||
def test_occurrences_between_rejects_malformed_dates() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
|
||||
occurrences_between(rule, "oops", "2026-09-05")
|
||||
with pytest.raises(RuleValidationError, match="YYYY-MM-DD"):
|
||||
occurrences_between(rule, "2026-09-05", "2026-9-5")
|
||||
|
||||
|
||||
def test_occurrences_between_leap_day_fires() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-02-27")
|
||||
# 2028 is a leap year: Feb 29 exists and is 2 days after Feb 27.
|
||||
result = occurrences_between(rule, "2028-02-28", "2028-03-01")
|
||||
assert result == ["2028-02-28", "2028-02-29", "2028-03-01"]
|
||||
|
||||
|
||||
def test_occurrences_between_year_boundary() -> None:
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-12-30")
|
||||
result = occurrences_between(rule, "2026-12-30", "2027-01-02")
|
||||
assert result == [
|
||||
"2026-12-30", "2026-12-31", "2027-01-01", "2027-01-02",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Purity: the engine raises on unknown shapes rather than guessing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_occurs_on_rejects_unknown_shapes_loudly() -> None:
|
||||
"""All six model shapes are implemented; a shape added to the model
|
||||
without an engine arm must raise a loud NotImplementedError rather
|
||||
than a silent wrong answer. Forged via a private enum extension
|
||||
because ScheduleRule validation rejects unknown names."""
|
||||
import enum as enum_module
|
||||
from custom_components.nestquest import recurrence as mod
|
||||
|
||||
ForgedType = enum_module.Enum(
|
||||
"ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"}
|
||||
)
|
||||
forged_rule = ScheduleRule.__new__(ScheduleRule)
|
||||
object.__setattr__(forged_rule, "rule_type", ForgedType.UNKNOWN_SHAPE)
|
||||
object.__setattr__(forged_rule, "interval", 1)
|
||||
object.__setattr__(forged_rule, "weekday_set", None)
|
||||
object.__setattr__(forged_rule, "day_of_month", None)
|
||||
object.__setattr__(forged_rule, "nth_weekday", None)
|
||||
object.__setattr__(forged_rule, "nth_weekday_weekday", None)
|
||||
object.__setattr__(forged_rule, "month", None)
|
||||
object.__setattr__(forged_rule, "start_date", "2026-09-01")
|
||||
object.__setattr__(forged_rule, "end_date", None)
|
||||
for day in ("2026-08-31", "2026-09-15", "2026-12-31"):
|
||||
with pytest.raises(NotImplementedError):
|
||||
occurs_on(forged_rule, _d(day))
|
||||
with pytest.raises(NotImplementedError):
|
||||
occurrences_between(forged_rule, "2026-08-01", "2026-12-31")
|
||||
|
||||
def test_interval_7_preserves_weekday_alignment_across_months() -> None:
|
||||
"""Weekly-by-interval semantics: every firing lands on the SAME
|
||||
weekday as start_date, no matter how many month edges intervene."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=7, start_date="2026-09-01"
|
||||
)
|
||||
# 2026-09-01 is a Tuesday; every firing must be a Tuesday too.
|
||||
assert _d("2026-09-01").weekday() == 1
|
||||
for day in ("2026-09-08", "2026-09-29", "2026-10-06", "2026-11-03"):
|
||||
assert occurs_on(rule, _d(day)) is True, day
|
||||
assert _d(day).weekday() == 1, f"{day} must be a Tuesday"
|
||||
# The day after a firing is never a firing.
|
||||
assert occurs_on(rule, _d("2026-10-07")) is False
|
||||
|
||||
|
||||
def test_interval_2_across_leap_day() -> None:
|
||||
"""Interval > 1 arithmetic across a leap day: the extra day shifts
|
||||
subsequent offsets by one, and the mod arithmetic reflects it."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
|
||||
)
|
||||
assert occurs_on(rule, _d("2028-02-27")) is True # 0
|
||||
assert occurs_on(rule, _d("2028-02-28")) is False # 1
|
||||
assert occurs_on(rule, _d("2028-02-29")) is True # 2 (leap day)
|
||||
assert occurs_on(rule, _d("2028-03-01")) is False # 3
|
||||
assert occurs_on(rule, _d("2028-03-02")) is True # 4
|
||||
|
||||
|
||||
def test_occurrences_between_leap_day_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2028-02-27"
|
||||
)
|
||||
result = occurrences_between(rule, "2028-02-27", "2028-03-03")
|
||||
assert result == ["2028-02-27", "2028-02-29", "2028-03-02"]
|
||||
|
||||
|
||||
def test_occurrences_between_year_boundary_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.DAILY, interval=2, start_date="2026-12-30"
|
||||
)
|
||||
result = occurrences_between(rule, "2026-12-30", "2027-01-04")
|
||||
# Offsets 0, 2, 4, 6 = Dec 30, Jan 1, Jan 3 ... wait: offsets are
|
||||
# Dec30=0, Dec31=1, Jan1=2, Jan2=3, Jan3=4, Jan4=5. Even offsets:
|
||||
assert result == ["2026-12-30", "2027-01-01", "2027-01-03"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Tests for recurrence.py: the ScheduleRule model and serialization."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
|
||||
def _rule(**kwargs) -> ScheduleRule:
|
||||
return ScheduleRule(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid construction for every rule type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_daily_rule_constructs_with_defaults() -> None:
|
||||
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
assert rule.rule_type is RuleType.DAILY
|
||||
assert rule.interval == 1
|
||||
assert rule.weekday_set is None
|
||||
assert rule.day_of_month is None
|
||||
assert rule.nth_weekday is None
|
||||
assert rule.month is None
|
||||
assert rule.end_date is None
|
||||
|
||||
|
||||
def test_string_rule_type_coerces() -> None:
|
||||
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
||||
assert rule.rule_type is RuleType.WEEKLY
|
||||
|
||||
|
||||
def test_weekly_rule_requires_and_freezes_weekday_set() -> None:
|
||||
rule = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4}, start_date="2026-09-01")
|
||||
assert rule.weekday_set == frozenset({0, 2, 4})
|
||||
# A mutable set passed in is frozen on construction.
|
||||
source = {1, 3}
|
||||
rule2 = _rule(rule_type=RuleType.WEEKLY, weekday_set=source, start_date="2026-09-01")
|
||||
source.add(5)
|
||||
assert rule2.weekday_set == frozenset({1, 3})
|
||||
|
||||
|
||||
def test_monthly_day_rule_fields() -> None:
|
||||
rule = _rule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15, start_date="2026-09-01")
|
||||
assert rule.day_of_month == 15
|
||||
assert rule.nth_weekday is None
|
||||
|
||||
|
||||
def test_monthly_weekday_rule_fields() -> None:
|
||||
rule = _rule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||
nth_weekday=2,
|
||||
nth_weekday_weekday=1,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
assert rule.nth_weekday == 2
|
||||
assert rule.nth_weekday_weekday == 1
|
||||
|
||||
|
||||
def test_monthly_weekday_last_friday_allowed() -> None:
|
||||
rule = _rule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY,
|
||||
nth_weekday=-1,
|
||||
nth_weekday_weekday=4,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
assert rule.nth_weekday == -1
|
||||
|
||||
|
||||
def test_yearly_rule_fields() -> None:
|
||||
rule = _rule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01")
|
||||
assert rule.month == 6
|
||||
|
||||
|
||||
def test_custom_days_rule_fields() -> None:
|
||||
rule = _rule(
|
||||
rule_type=RuleType.CUSTOM_DAYS,
|
||||
weekday_set={1, 3, 5},
|
||||
interval=2,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
assert rule.weekday_set == frozenset({1, 3, 5})
|
||||
assert rule.interval == 2
|
||||
|
||||
|
||||
def test_end_date_round_trips(tmp_path) -> None:
|
||||
rule = _rule(
|
||||
rule_type=RuleType.DAILY,
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-12-31",
|
||||
)
|
||||
assert rule.end_date == "2026-12-31"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invalid combinations are rejected at construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
# weekly without weekday_set
|
||||
{"rule_type": RuleType.WEEKLY.value, "start_date": "2026-09-01"},
|
||||
# daily carrying a weekday_set
|
||||
{"rule_type": "daily", "weekday_set": {0}, "start_date": "2026-09-01"},
|
||||
# monthly_day without day_of_month
|
||||
{"rule_type": "monthly_day", "start_date": "2026-09-01"},
|
||||
# monthly_day out of range
|
||||
{"rule_type": "monthly_day", "day_of_month": 32, "start_date": "2026-09-01"},
|
||||
# monthly_day carrying weekday_set
|
||||
{"rule_type": "monthly_day", "day_of_month": 5, "weekday_set": {0},
|
||||
"start_date": "2026-09-01"},
|
||||
# yearly without month
|
||||
{"rule_type": "yearly", "start_date": "2026-09-01"},
|
||||
# yearly out of range
|
||||
{"rule_type": "yearly", "month": 13, "start_date": "2026-09-01"},
|
||||
# unknown type
|
||||
{"rule_type": "bogus", "start_date": "2026-09-01"},
|
||||
# interval zero
|
||||
{"rule_type": "daily", "interval": 0, "start_date": "2026-09-01"},
|
||||
# interval non-integer
|
||||
{"rule_type": "daily", "interval": "2", "start_date": "2026-09-01"},
|
||||
# end before start
|
||||
{"rule_type": "daily", "start_date": "2026-09-01",
|
||||
"end_date": "2026-08-31"},
|
||||
# malformed dates
|
||||
{"rule_type": "daily", "start_date": "not-a-date"},
|
||||
{"rule_type": "daily", "start_date": "2026-9-1"},
|
||||
# weekly weekday out of range
|
||||
{"rule_type": RuleType.WEEKLY.value, "weekday_set": {7}, "start_date": "2026-09-01"},
|
||||
# weekly empty set
|
||||
{"rule_type": RuleType.WEEKLY.value, "weekday_set": set(), "start_date": "2026-09-01"},
|
||||
# custom_days carrying month-specific fields
|
||||
{"rule_type": "custom_days", "weekday_set": {0}, "day_of_month": 5,
|
||||
"start_date": "2026-09-01"},
|
||||
{"rule_type": "custom_days", "weekday_set": {0}, "nth_weekday": 1,
|
||||
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
||||
{"rule_type": "custom_days", "weekday_set": {0}, "month": 3,
|
||||
"start_date": "2026-09-01"},
|
||||
# monthly_weekday position invalid
|
||||
{"rule_type": "monthly_weekday", "nth_weekday": 0,
|
||||
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
||||
{"rule_type": "monthly_weekday", "nth_weekday": 6,
|
||||
"nth_weekday_weekday": 1, "start_date": "2026-09-01"},
|
||||
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
||||
"nth_weekday_weekday": None, "start_date": "2026-09-01"},
|
||||
{"rule_type": "monthly_weekday", "nth_weekday": 2,
|
||||
"nth_weekday_weekday": 7, "start_date": "2026-09-01"},
|
||||
# monthly_day carrying nth fields
|
||||
{"rule_type": "monthly_day", "day_of_month": 5, "nth_weekday": 1,
|
||||
"nth_weekday_weekday": 0, "start_date": "2026-09-01"},
|
||||
],
|
||||
)
|
||||
def test_invalid_combinations_raise(kwargs) -> None:
|
||||
with pytest.raises(RuleValidationError):
|
||||
_rule(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Immutability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rule_is_immutable() -> None:
|
||||
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
with pytest.raises(AttributeError):
|
||||
rule.interval = 2
|
||||
with pytest.raises(AttributeError):
|
||||
del rule.interval
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization round-trip for every rule type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule",
|
||||
[
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.DAILY, interval=3, start_date="2026-09-01",
|
||||
end_date="2026-12-31"),
|
||||
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.WEEKLY, weekday_set={6}, interval=2,
|
||||
start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=3,
|
||||
start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=1, start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||
nth_weekday_weekday=4, interval=2,
|
||||
start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.YEARLY, month=6, start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.YEARLY, month=2, day_of_month=None,
|
||||
interval=2, start_date="2026-09-01"),
|
||||
ScheduleRule(rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
|
||||
interval=2, start_date="2026-09-01"),
|
||||
],
|
||||
)
|
||||
def test_to_dict_from_dict_round_trips_losslessly(rule) -> None:
|
||||
rebuilt = ScheduleRule.from_dict(rule.to_dict())
|
||||
assert rebuilt == rule
|
||||
# Round-tripping twice is stable.
|
||||
assert ScheduleRule.from_dict(rebuilt.to_dict()) == rule
|
||||
|
||||
|
||||
def test_from_dict_rejects_unknown_fields() -> None:
|
||||
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
||||
ScheduleRule.from_dict({"rule_type": "daily", "bogus": 1})
|
||||
|
||||
|
||||
def test_from_dict_rejects_missing_rule_type() -> None:
|
||||
with pytest.raises(RuleValidationError, match="missing required"):
|
||||
ScheduleRule.from_dict({"interval": 1})
|
||||
|
||||
|
||||
def test_from_dict_rejects_non_dict() -> None:
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule.from_dict("daily")
|
||||
|
||||
|
||||
def test_from_dict_defaults_start_date_when_absent() -> None:
|
||||
rule = ScheduleRule.from_dict({"rule_type": "daily"})
|
||||
assert rule.start_date == "1970-01-01"
|
||||
|
||||
|
||||
def test_from_dict_accepts_list_weekday_set() -> None:
|
||||
rule = ScheduleRule.from_dict(
|
||||
{"rule_type": RuleType.WEEKLY.value, "weekday_set": [0, 2, 4]}
|
||||
)
|
||||
assert rule.weekday_set == frozenset({0, 2, 4})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equality and hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_equal_rules_compare_equal_and_hash_equal() -> None:
|
||||
a = _rule(rule_type=RuleType.WEEKLY, weekday_set={0, 2}, start_date="2026-09-01")
|
||||
b = _rule(rule_type=RuleType.WEEKLY, weekday_set={2, 0}, start_date="2026-09-01")
|
||||
assert a == b
|
||||
assert hash(a) == hash(b)
|
||||
|
||||
|
||||
def test_different_rules_compare_unequal() -> None:
|
||||
a = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
b = _rule(rule_type=RuleType.DAILY, interval=2, start_date="2026-09-01")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_rule_not_equal_to_other_types() -> None:
|
||||
rule = _rule(rule_type=RuleType.DAILY, start_date="2026-09-01")
|
||||
assert rule != "daily"
|
||||
assert (rule == 42) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Purity guards: no HA imports, no database access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_recurrence_module_imports_only_stdlib() -> None:
|
||||
"""The model must carry no Home Assistant and no DB imports."""
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(
|
||||
__import__("custom_components.nestquest.recurrence",
|
||||
fromlist=["__file__"]).__file__
|
||||
).read_text()
|
||||
offenders = _scan_forbidden_imports(source)
|
||||
assert offenders == [], (
|
||||
f"recurrence.py must not hold forbidden imports: {offenders}"
|
||||
)
|
||||
|
||||
|
||||
def _scan_forbidden_imports(source: str) -> list[str]:
|
||||
"""The ACTUAL purity-guard logic, shared by the model scan and the
|
||||
probe tests below: returns human-readable offenders (empty = clean).
|
||||
|
||||
Forbidden, at any relative level, for both Import and ImportFrom:
|
||||
- modules containing 'homeassistant' or 'sqlite'
|
||||
- modules whose LEAF name is a DB/store/schema/DAO module
|
||||
- aliases importing a DB/store/schema/DAO module by leaf name
|
||||
('from . import db', 'from custom_components.nestquest import db')
|
||||
"""
|
||||
import ast
|
||||
|
||||
forbidden_targets = {
|
||||
"db", "schema", "dao_children", "dao_rules", "dao_presence",
|
||||
"dao_instances", "migrations", "store",
|
||||
}
|
||||
offenders: list[str] = []
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
lowered = alias.name.lower()
|
||||
leaf = lowered.split(".")[-1]
|
||||
if (
|
||||
"homeassistant" in lowered
|
||||
or "sqlite" in lowered
|
||||
or leaf in forbidden_targets
|
||||
):
|
||||
offenders.append(f"import {alias.name}")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = (node.module or "").lower()
|
||||
module_leaf = module.split(".")[-1] if module else ""
|
||||
if (
|
||||
"homeassistant" in module
|
||||
or "sqlite" in module
|
||||
or (module and module_leaf in forbidden_targets)
|
||||
or (node.level and module_leaf in forbidden_targets)
|
||||
):
|
||||
offenders.append(
|
||||
f"from {node.module!r} import ... (level={node.level})"
|
||||
)
|
||||
for alias in node.names:
|
||||
alias_leaf = alias.name.split(".")[-1].lower()
|
||||
if alias_leaf in forbidden_targets:
|
||||
offenders.append(
|
||||
f"from {node.module!r} import {alias.name} "
|
||||
f"(level={node.level})"
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None:
|
||||
"""The shared scan function catches every forbidden import shape,
|
||||
including level-0 absolute package DB imports and 'from . import db'.
|
||||
"""
|
||||
probes = [
|
||||
"from homeassistant.core import HomeAssistant",
|
||||
"import sqlite3",
|
||||
"from .db import NestQuestDatabase",
|
||||
"from custom_components.nestquest.db import NestQuestDatabase",
|
||||
"from custom_components.nestquest import db",
|
||||
"import custom_components.nestquest.dao_rules",
|
||||
"from . import db",
|
||||
"from . import schema",
|
||||
"from custom_components.nestquest.migrations import apply_migrations",
|
||||
]
|
||||
for probe in probes:
|
||||
offenders = _scan_forbidden_imports(probe)
|
||||
assert offenders, f"probe must be caught: {probe!r}"
|
||||
|
||||
|
||||
def test_non_string_rule_type_rejected() -> None:
|
||||
"""Only RuleType members and their string values construct."""
|
||||
for bad in (123, True, None, 3.5, b"daily"):
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
||||
|
||||
|
||||
def test_weekly_rejects_month_and_nth_fields() -> None:
|
||||
with pytest.raises(RuleValidationError, match="must not set"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, day_of_month=5,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="must not set"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, month=3,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_daily_rejects_dangling_nth_weekday_weekday() -> None:
|
||||
with pytest.raises(RuleValidationError, match="must not set"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, nth_weekday_weekday=0,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_monthly_day_rejects_month() -> None:
|
||||
with pytest.raises(RuleValidationError, match="must not set"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=5, month=3,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_yearly_rejects_day_and_nth_fields() -> None:
|
||||
with pytest.raises(RuleValidationError, match="must not set"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, nth_weekday=1,
|
||||
nth_weekday_weekday=0, start_date="2026-09-01",
|
||||
)
|
||||
# YEARLY MAY carry an optional day_of_month (Feb 29 rules need it):
|
||||
rule = ScheduleRule(rule_type=RuleType.YEARLY, month=2,
|
||||
day_of_month=29, start_date="2026-09-01")
|
||||
assert rule.day_of_month == 29
|
||||
# ...but it is still validated when present.
|
||||
with pytest.raises(RuleValidationError, match="day_of_month"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=32,
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_storage_value_mapping() -> None:
|
||||
"""The enum exposes the schema's CHECK values via storage_value."""
|
||||
assert RuleType.DAILY.storage_value == "daily"
|
||||
assert RuleType.WEEKLY.storage_value == "weekly"
|
||||
assert RuleType.MONTHLY_DAY.storage_value == "monthly"
|
||||
assert RuleType.MONTHLY_WEEKDAY.storage_value == "monthly"
|
||||
assert RuleType.YEARLY.storage_value == "yearly"
|
||||
assert RuleType.CUSTOM_DAYS.storage_value == "custom"
|
||||
|
||||
|
||||
def test_from_dict_bool_weekday_entry_rejected_before_set_coercion() -> None:
|
||||
"""[0, False] must NOT collapse False into 0 via set() coercion."""
|
||||
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "weekly", "weekday_set": [0, False]}
|
||||
)
|
||||
|
||||
|
||||
def test_direct_set_with_int_subclass_entry_rejected() -> None:
|
||||
"""A hashable-but-non-plain int subclass is still not a plain int."""
|
||||
|
||||
class IntChild(int):
|
||||
pass
|
||||
|
||||
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY,
|
||||
weekday_set={IntChild(1)},
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_from_dict_with_int_subclass_entry_rejected() -> None:
|
||||
class IntChild(int):
|
||||
pass
|
||||
|
||||
with pytest.raises(RuleValidationError, match="plain integers"):
|
||||
ScheduleRule.from_dict(
|
||||
{"rule_type": "weekly", "weekday_set": [IntChild(1)]}
|
||||
)
|
||||
|
||||
|
||||
def test_from_dict_unknown_key_types_never_leak_typeerror() -> None:
|
||||
"""Mixed unhashable unknown keys must surface as
|
||||
RuleValidationError, not a raw TypeError from sorting."""
|
||||
with pytest.raises(RuleValidationError, match="unknown rule fields"):
|
||||
ScheduleRule.from_dict({"rule_type": "daily", 42: "value"})
|
||||
|
||||
|
||||
def test_storage_aliases_and_whitespace_are_not_model_names() -> None:
|
||||
"""The model boundary is exact names only: storage strings, padded
|
||||
and case variants are all rejected."""
|
||||
for bad in ("monthly", "custom", " DAILY ", "daily ", "DAILY"):
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(rule_type=bad, start_date="2026-09-01")
|
||||
|
||||
|
||||
def test_unhashable_int_subclass_interval_rejected() -> None:
|
||||
class UnhashableInt(int):
|
||||
__hash__ = None
|
||||
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY,
|
||||
interval=UnhashableInt(1),
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
def test_rule_type_is_a_dataclass() -> None:
|
||||
import dataclasses
|
||||
|
||||
assert dataclasses.is_dataclass(ScheduleRule)
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Tests for MONTHLY_DAY recurrence with the month-end clamp policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_month_end,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nominal-day firings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_day_15_fires_on_the_15th() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
for month in range(1, 13):
|
||||
assert occurs_on(rule, datetime.date(2026, month, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 14)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 16)) is False
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_on_other_days() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=10,
|
||||
start_date="2026-01-10",
|
||||
)
|
||||
# Every non-10th day of a short month must be False.
|
||||
for day in range(1, 30):
|
||||
if day != 10:
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, 1, day)
|
||||
) is False, f"Jan {day}"
|
||||
|
||||
|
||||
def test_monthly_day_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-03-15",
|
||||
)
|
||||
# Feb 15 precedes the start: no firing even though the day matches.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15", end_date="2026-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Month-end clamp policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_month_end_helper_is_leap_aware() -> None:
|
||||
assert _month_end(2026, 1) == 31
|
||||
assert _month_end(2026, 4) == 30
|
||||
assert _month_end(2026, 2) == 28 # non-leap
|
||||
assert _month_end(2028, 2) == 29 # leap
|
||||
assert _month_end(2000, 2) == 29 # divisible-by-400 leap
|
||||
assert _month_end(1900, 2) == 28 # divisible-by-100 non-leap
|
||||
assert _month_end(2026, 12) == 31
|
||||
|
||||
|
||||
def test_day_31_clamps_to_month_end_in_shorter_months() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Months with a 31st: fire on the 31st.
|
||||
for month, day in ((1, 31), (3, 31), (5, 31), (7, 31), (8, 31),
|
||||
(10, 31), (12, 31)):
|
||||
assert occurs_on(rule, datetime.date(2026, month, day)) is True, (
|
||||
f"2026-{month:02d}-{day:02d}"
|
||||
)
|
||||
# Months WITHOUT a 31st: fire on the month's last day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True # Apr 30
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True # Jun 30
|
||||
assert occurs_on(rule, datetime.date(2026, 9, 30)) is True # Sep 30
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 30)) is True # Nov 30
|
||||
# The day BEFORE the clamp is not a firing.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 29)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_clamps_to_feb_28_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2026-01-30",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # clamp
|
||||
# No Feb 29 in 2026 (non-leap): nothing else in February fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
|
||||
# Months with a 30th: nominal day.
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 30)) is True
|
||||
|
||||
|
||||
def test_day_29_feb_clamps_to_28_non_leap_and_29_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2026-01-29",
|
||||
)
|
||||
# Non-leap 2027: Feb 28 is the clamp.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 27)) is False
|
||||
# Leap 2028: Feb 29 exists — nominal day.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
def test_day_30_feb_leap_year_clamps_to_29() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2027-01-30",
|
||||
)
|
||||
# Leap 2028: Feb has 29 days; the 30th clamps to Feb 29.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
# Non-leap 2026 (rule starts 2027 so Feb 2027 is non-leap): clamps 28.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full-year sweeps: the 29th, 30th, 31st across leap and non-leap years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sweep_monthly(rule: ScheduleRule, year: int) -> list[tuple[int, int]]:
|
||||
"""Every (month, day) in ``rule``'s year the engine says fires."""
|
||||
hits = []
|
||||
for month in range(1, 13):
|
||||
for day in range(1, _month_end(year, month) + 1):
|
||||
if occurs_on(rule, datetime.date(year, month, day)):
|
||||
hits.append((month, day))
|
||||
return hits
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2026() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
# Exactly one firing per month, twelve total.
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
assert len(hits) == 12
|
||||
by_month = dict(hits)
|
||||
assert (by_month[1], by_month[2], by_month[4]) == (31, 28, 30)
|
||||
assert by_month[2] == 28 # 2026 Feb is non-leap
|
||||
assert by_month[4] == 30
|
||||
# Each month has exactly one firing.
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_31_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2028-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap February clamps to the 29th
|
||||
assert by_month[4] == 30
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_30_sweep_2028() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=30,
|
||||
start_date="2028-01-30",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb clamps to 29
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2027_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2027-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2027)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 28 # non-leap Feb clamps to the 28th
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
def test_full_year_day_29_sweep_2028_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=29,
|
||||
start_date="2028-01-29",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2028)
|
||||
by_month = dict(hits)
|
||||
assert by_month[2] == 29 # leap Feb has a real 29th: nominal day
|
||||
months = [month for month, _ in hits]
|
||||
assert months == list(range(1, 13))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_interval_2_every_second_month() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_interval_2_clamped_month_end_alignment() -> None:
|
||||
"""Interval counts nominal months from the anchor: Jan 31 anchor,
|
||||
every 2 months -> Jan (m0), Mar (m2), May (m4), Jul (m6). Clamped
|
||||
firings (Feb 28, Apr 30, Jun 30) occur only in interval months."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31, interval=2,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, 31)) is True # m0
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is False # m1
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 31)) is True # m2
|
||||
assert occurs_on(rule, datetime.date(2026, 4, 30)) is False # m3
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 31)) is True # m4
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is False # m5
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 31)) is True # m6
|
||||
|
||||
|
||||
def test_monthly_interval_arithmetic_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15, interval=2,
|
||||
start_date="2026-11-15",
|
||||
)
|
||||
# Nov (m0) fires, Jan 2027 (m2) fires, Mar (m4) fires.
|
||||
assert occurs_on(rule, datetime.date(2026, 11, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 12, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 1, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 3, 15)) is True
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-06-30")
|
||||
assert result == [
|
||||
"2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30",
|
||||
"2026-05-31", "2026-06-30",
|
||||
]
|
||||
|
||||
|
||||
def test_monthly_day_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=15,
|
||||
start_date="2026-01-15",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-06-15", "2026-01-15")
|
||||
|
||||
|
||||
def test_monthly_day_clamp_is_one_firing_per_month(tmp_path) -> None:
|
||||
"""The clamp must not double-fire: only one day per month matches."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_DAY, day_of_month=31,
|
||||
start_date="2026-01-31",
|
||||
)
|
||||
hits = _sweep_monthly(rule, 2026)
|
||||
months = [month for month, _ in hits]
|
||||
assert months == sorted(months)
|
||||
assert len(months) == len(set(months)) == 12
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Tests for MONTHLY_WEEKDAY (nth-weekday-of-month) recurrence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
_nth_weekday_of_month,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The helper: nth occurrence day-of-month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_nth_weekday_helper_first_second_third() -> None:
|
||||
"""Independently verified: Mar 1 2026 is a Sunday; Wednesdays are
|
||||
4, 11, 18, 25 (four — no 5th)."""
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 1) == 4
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 2) == 11
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 3) == 18
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 4) == 25
|
||||
assert _nth_weekday_of_month(2026, 3, 2, 5) is None
|
||||
|
||||
|
||||
def test_nth_weekday_helper_none_when_month_lacks_nth() -> None:
|
||||
"""Independently verified: April 2026 Fridays are 3, 10, 17, 24
|
||||
(four) — no 5th occurrence."""
|
||||
assert _nth_weekday_of_month(2026, 4, 4, 5) is None
|
||||
|
||||
|
||||
def test_nth_weekday_helper_last() -> None:
|
||||
"""Independently verified: March 2026 Fridays are 6, 13, 20, 27;
|
||||
February 2028 Saturdays are 5, 12, 19, 26 (Feb 29 2028 is a
|
||||
Tuesday, so the last SATURDAY is the 26th, not the last day)."""
|
||||
assert _nth_weekday_of_month(2026, 3, 4, -1) == 27
|
||||
# February 2026: 28 days, last day IS a Saturday.
|
||||
assert _nth_weekday_of_month(2026, 2, 5, -1) == 28
|
||||
# February 2028: the last Saturday is the 26th (Feb 29 is a Tue).
|
||||
assert _nth_weekday_of_month(2028, 2, 5, -1) == 26
|
||||
|
||||
|
||||
def test_nth_weekday_helper_last_when_month_has_five() -> None:
|
||||
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
|
||||
(five) — the last is the 30th, and the 5th is the same day."""
|
||||
assert _nth_weekday_of_month(2026, 10, 4, -1) == 30
|
||||
assert _nth_weekday_of_month(2026, 10, 4, 5) == 30
|
||||
|
||||
|
||||
def test_helper_zero_and_out_of_range_nth() -> None:
|
||||
"""The model rejects n 0 before the helper sees it; -1/1..5 only."""
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=0,
|
||||
nth_weekday_weekday=1, start_date="2026-09-01",
|
||||
)
|
||||
with pytest.raises(RuleValidationError):
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=6,
|
||||
nth_weekday_weekday=1, start_date="2026-09-01",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurs_on: first / second / third / fourth / last across 12 months
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_first_tuesday_of_twelve_consecutive_months() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, # first Wednesday
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
first_wednesdays = [
|
||||
(1, 7), (2, 4), (3, 4), (4, 1), (5, 6), (6, 3),
|
||||
(7, 1), (8, 5), (9, 2), (10, 7), (11, 4), (12, 2),
|
||||
]
|
||||
for month, day in first_wednesdays:
|
||||
date = datetime.date(2026, month, day)
|
||||
assert date.weekday() == 2
|
||||
assert occurs_on(rule, date) is True, date
|
||||
# Neighbors do not fire.
|
||||
assert occurs_on(rule, date - datetime.timedelta(days=7)) is False
|
||||
assert occurs_on(rule, date + datetime.timedelta(days=7)) is False
|
||||
|
||||
|
||||
def test_second_wednesday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified second Wednesdays of 2026 (Jan 1 is a
|
||||
Thursday; Wednesdays per month from the calendar): the SECOND one
|
||||
fires; the FIRST one does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
# (month, first_wed, second_wed) from plain datetime arithmetic:
|
||||
verified = [
|
||||
(1, 7, 14), (2, 4, 11), (3, 4, 11), (4, 1, 8),
|
||||
(5, 6, 13), (6, 3, 10), (7, 1, 8), (8, 5, 12),
|
||||
(9, 2, 9), (10, 7, 14), (11, 4, 11), (12, 2, 9),
|
||||
]
|
||||
for month, first_wed, second_wed in verified:
|
||||
assert _d(f"2026-{month:02d}-{first_wed:02d}").weekday() == 2
|
||||
assert _d(f"2026-{month:02d}-{second_wed:02d}").weekday() == 2
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, second_wed)
|
||||
) is True, f"2026-{month:02d}-{second_wed:02d}"
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, first_wed)
|
||||
) is False, f"2026-{month:02d}-{first_wed:02d}"
|
||||
|
||||
|
||||
def test_third_friday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified 2026 Fridays; the THIRD fires, the SECOND
|
||||
does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=3,
|
||||
nth_weekday_weekday=4,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, first_fri, second_fri, third_fri)
|
||||
(1, 2, 9, 16), (2, 6, 13, 20), (3, 6, 13, 20),
|
||||
(4, 3, 10, 17), (5, 1, 8, 15), (6, 5, 12, 19),
|
||||
(7, 3, 10, 17), (8, 7, 14, 21), (9, 4, 11, 18),
|
||||
(10, 2, 9, 16), (11, 6, 13, 20), (12, 4, 11, 18),
|
||||
]
|
||||
for month, first_fri, second_fri, third_fri in verified:
|
||||
third = _d(f"2026-{month:02d}-{third_fri:02d}")
|
||||
assert third.weekday() == 4
|
||||
assert occurs_on(rule, third) is True, third
|
||||
second = _d(f"2026-{month:02d}-{second_fri:02d}")
|
||||
assert occurs_on(rule, second) is False, second
|
||||
|
||||
|
||||
def test_fourth_wednesday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified 2026 Wednesdays; the FOURTH fires, the
|
||||
THIRD does not."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=4,
|
||||
nth_weekday_weekday=2,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, third_wed, fourth_wed)
|
||||
(1, 21, 28), (2, 18, 25), (3, 18, 25), (4, 15, 22),
|
||||
(5, 20, 27), (6, 17, 24), (7, 15, 22), (8, 19, 26),
|
||||
(9, 16, 23), (10, 21, 28), (11, 18, 25), (12, 16, 23),
|
||||
]
|
||||
for month, third_wed, fourth_wed in verified:
|
||||
fourth = _d(f"2026-{month:02d}-{fourth_wed:02d}")
|
||||
assert fourth.weekday() == 2
|
||||
assert occurs_on(rule, fourth) is True, fourth
|
||||
third = _d(f"2026-{month:02d}-{third_wed:02d}")
|
||||
assert not occurs_on(rule, third), third
|
||||
|
||||
|
||||
def test_last_monday_of_twelve_consecutive_months() -> None:
|
||||
"""Independently verified last Mondays of 2026; each fires, and the
|
||||
PREVIOUS Monday of the same month does not (it is nth-from-last
|
||||
minus one, i.e. the second-to-last)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=-1,
|
||||
nth_weekday_weekday=0,
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
verified = [
|
||||
# (month, last_monday)
|
||||
(1, 26), (2, 23), (3, 30), (4, 27), (5, 25), (6, 29),
|
||||
(7, 27), (8, 31), (9, 28), (10, 26), (11, 30), (12, 28),
|
||||
]
|
||||
for month, last_monday in verified:
|
||||
date = _d(f"2026-{month:02d}-{last_monday:02d}")
|
||||
assert date.weekday() == 0
|
||||
assert occurs_on(rule, date) is True, date
|
||||
# The Monday a week earlier is NOT the last: must not fire.
|
||||
week_before = date - datetime.timedelta(days=7)
|
||||
if week_before.month == month:
|
||||
assert occurs_on(rule, week_before) is False, week_before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The five-occurrence skip: a 5th on a four-occurrence month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fifth_weekday_skips_four_occurrence_months() -> None:
|
||||
"""Independently verified: 2026 months with FIVE Mondays are March,
|
||||
June, August, November — every other month (four Mondays) must fire
|
||||
on no day at all, and each hit must be the month's FINAL Monday."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||
nth_weekday_weekday=0, # fifth Monday
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
five_monday_months = {3: 30, 6: 29, 8: 31, 11: 30}
|
||||
hit_months = set()
|
||||
for month in range(1, 13):
|
||||
if month in five_monday_months:
|
||||
day = five_monday_months[month]
|
||||
hit = _d(f"2026-{month:02d}-{day:02d}")
|
||||
assert hit.weekday() == 0
|
||||
assert occurs_on(rule, hit) is True, hit
|
||||
# It is the FINAL Monday of the month: sweep EVERY remaining
|
||||
# calendar day after the hit — none may fire.
|
||||
for later in range(day + 1, _month_end_days(2026, month) + 1):
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, later)
|
||||
) is False, f"2026-{month:02d}-{later:02d}"
|
||||
# The Monday a week earlier is not the fifth.
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, day - 7)
|
||||
) is False
|
||||
hit_months.add(month)
|
||||
else:
|
||||
for day in range(1, _month_end_days(2026, month) + 1):
|
||||
assert occurs_on(
|
||||
rule, datetime.date(2026, month, day)
|
||||
) is False, f"2026-{month:02d}-{day:02d}"
|
||||
assert hit_months == {3, 6, 8, 11}, hit_months
|
||||
|
||||
|
||||
def _month_end_days(year: int, month: int) -> int:
|
||||
if month == 12:
|
||||
return 31
|
||||
return (datetime.date(year, month + 1, 1)
|
||||
- datetime.timedelta(days=1)).day
|
||||
|
||||
|
||||
def test_fifth_weekday_fires_in_five_occurrence_months() -> None:
|
||||
"""Independently verified: October 2026 Fridays are 2, 9, 16, 23, 30
|
||||
(five) — the 5th (and last) is the 30th; April 2026 has four Fridays
|
||||
(3, 10, 17, 24) so no day in April fires."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=5,
|
||||
nth_weekday_weekday=4, # Fridays
|
||||
start_date="2026-01-01",
|
||||
)
|
||||
# October: the 30th fires, is a Friday, and no Monday-later day
|
||||
# (the 31st) is a Friday: the 30th is the FINAL Friday.
|
||||
oct_30 = datetime.date(2026, 10, 30)
|
||||
assert oct_30.weekday() == 4
|
||||
assert occurs_on(rule, oct_30) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 10, 31)) is False
|
||||
# April: no Friday-5th, so no day fires.
|
||||
for day in range(1, _month_end_days(2026, 4) + 1):
|
||||
assert occurs_on(rule, datetime.date(2026, 4, day)) is False, (
|
||||
f"2026-04-{day:02d}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval arithmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_weekday_interval_2() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=2, interval=2, start_date="2026-01-01",
|
||||
)
|
||||
# Jan (m0) fires on the 2nd Tuesday; Feb (m1) skipped; Mar (m2) fires.
|
||||
jan_second = _nth_weekday_of_month(2026, 1, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 1, jan_second)) is True
|
||||
feb_second = _nth_weekday_of_month(2026, 2, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, feb_second)) is False
|
||||
mar_second = _nth_weekday_of_month(2026, 3, 2, 2)
|
||||
assert occurs_on(rule, datetime.date(2026, 3, mar_second)) is True
|
||||
|
||||
|
||||
def test_monthly_weekday_interval_across_year_boundary() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=2, interval=3, start_date="2026-11-01",
|
||||
)
|
||||
# Nov (m0) fires; Dec (m1), Jan (m2) skip; Feb (m3) fires.
|
||||
nov_first = _nth_weekday_of_month(2026, 11, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2026, 11, nov_first)) is True
|
||||
dec_first = _nth_weekday_of_month(2026, 12, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2026, 12, dec_first)) is False
|
||||
jan_first = _nth_weekday_of_month(2027, 1, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2027, 1, jan_first)) is False
|
||||
feb_first = _nth_weekday_of_month(2027, 2, 2, 1)
|
||||
assert occurs_on(rule, datetime.date(2027, 2, feb_first)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monthly_weekday_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=2,
|
||||
nth_weekday_weekday=4, # second Friday
|
||||
start_date="2026-01-01", end_date="2026-04-30",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2026-04-30")
|
||||
# Independently verified second Fridays: Jan 9 (Jan 1 Thu), Feb 13
|
||||
# (Feb 1 Sun), Mar 13 (Mar 1 Sun), Apr 10 (Apr 1 Wed).
|
||||
assert result == [
|
||||
"2026-01-09", "2026-02-13", "2026-03-13", "2026-04-10",
|
||||
]
|
||||
|
||||
|
||||
def test_monthly_weekday_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.MONTHLY_WEEKDAY, nth_weekday=1,
|
||||
nth_weekday_weekday=1, start_date="2026-01-01",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-06-01", "2026-01-01")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Tests for weekly and custom day-set recurrence evaluation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# 2026-09-01 is a TUESDAY (weekday 1).
|
||||
# Anchor-week arithmetic (start_date = Tue Sep 1): week 0 spans
|
||||
# Sep 1-7, week 1 spans Sep 8-14, week 2 spans Sep 15-21, and so on —
|
||||
# whole weeks elapsed from the anchor, NOT calendar-Sunday weeks.
|
||||
# A weekly Mon/Wed/Fri (0,2,4) rule at interval 1 anchored here fires
|
||||
# on Wed Sep 2, Fri Sep 4 (both week 0), then Mon Sep 7, Wed Sep 9,
|
||||
# Fri Sep 11 (week 1), Mon Sep 14... (week 2), and so on.
|
||||
|
||||
|
||||
def test_weekly_fires_exactly_on_set_weekdays() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
# Sep 2026: 1=Tue(1), 2=Wed(2), 4=Fri(4), 7=Mon(0), 8=Tue(1), 9=Wed,
|
||||
# 10=Thu(3), 11=Fri(4)
|
||||
assert occurs_on(rule, _d("2026-09-01")) is False # Tue NOT in set
|
||||
assert occurs_on(rule, _d("2026-09-02")) is True # Wed in set
|
||||
assert occurs_on(rule, _d("2026-09-03")) is False # Thu not in set
|
||||
assert occurs_on(rule, _d("2026-09-04")) is True # Fri in set
|
||||
assert occurs_on(rule, _d("2026-09-05")) is False # Sat
|
||||
assert occurs_on(rule, _d("2026-09-06")) is False # Sun
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True # Mon
|
||||
assert occurs_on(rule, _d("2026-09-08")) is False # Tue not in set
|
||||
|
||||
|
||||
def test_weekly_start_date_itself_may_not_be_in_set() -> None:
|
||||
"""start_date anchors the WEEK count, not a firing day: a Tuesday
|
||||
start for a Mon/Wed/Fri rule fires on Wed Sep 2, not Sep 1."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-01")) is False # Tue=1, not in set
|
||||
assert occurs_on(rule, _d("2026-09-02")) is True # Wed=2
|
||||
assert occurs_on(rule, _d("2026-09-04")) is True # Fri=4
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, next week
|
||||
|
||||
|
||||
def test_weekly_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={2}, # Wednesdays
|
||||
start_date="2026-09-02", # a Wednesday
|
||||
)
|
||||
# Wed Sep 2 is the anchor day and in-set: fires. Wed Aug 26 (one
|
||||
# week BEFORE the anchor, in-set) must not fire: pre-window.
|
||||
assert occurs_on(rule, _d("2026-09-02")) is True
|
||||
assert occurs_on(rule, _d("2026-08-26")) is False # Wed, before start
|
||||
assert occurs_on(rule, _d("2026-09-09")) is True # Wed, next week
|
||||
|
||||
|
||||
def test_weekly_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-09-01", end_date="2026-09-10",
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-09")) is True # Wed within end
|
||||
assert occurs_on(rule, _d("2026-09-11")) is False # Fri after end
|
||||
|
||||
|
||||
def test_weekly_interval_2_fires_on_alternate_weeks() -> None:
|
||||
"""Interval 2: fires on set weekdays only in even week-offsets."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
|
||||
start_date="2026-09-07", # a Monday
|
||||
)
|
||||
# Week 0 (Sep 7-13): fires Mon Sep 7.
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True
|
||||
# Week 1 (Sep 14-20): skipped.
|
||||
assert occurs_on(rule, _d("2026-09-14")) is False
|
||||
# Week 2 (Sep 21-27): fires Mon Sep 21.
|
||||
assert occurs_on(rule, _d("2026-09-21")) is True
|
||||
# Week 3 (Sep 28-Oct 4): skipped; Mon Sep 28 does not fire.
|
||||
assert occurs_on(rule, _d("2026-09-28")) is False
|
||||
# Week 4 (Oct 5-11): fires Mon Oct 5.
|
||||
assert occurs_on(rule, _d("2026-10-05")) is True
|
||||
|
||||
|
||||
def test_weekly_interval_3_every_third_week() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={3}, interval=3,
|
||||
start_date="2026-09-03", # a Thursday
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-03")) is True # week 0
|
||||
assert occurs_on(rule, _d("2026-09-10")) is False # week 1
|
||||
assert occurs_on(rule, _d("2026-09-17")) is False # week 2
|
||||
assert occurs_on(rule, _d("2026-09-24")) is True # week 3
|
||||
assert occurs_on(rule, _d("2026-10-15")) is True # week 6
|
||||
|
||||
|
||||
def test_weekly_interval_2_never_fires_in_odd_weeks_across_months() -> None:
|
||||
"""The week offset is anchored to start_date across month edges."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={2}, interval=2,
|
||||
start_date="2026-09-02", # a Wednesday
|
||||
)
|
||||
# Week 0: Wed Sep 2 fires. Week 1: Wed Sep 9 skipped.
|
||||
# Week 2: Wed Sep 16 fires. Week 3: Wed Sep 23 skipped.
|
||||
# Week 4 (Oct): Wed Sep 30 fires (still week 4!). Week 5: Wed Oct 7 skipped.
|
||||
assert occurs_on(rule, _d("2026-09-02")) is True
|
||||
assert occurs_on(rule, _d("2026-09-09")) is False
|
||||
assert occurs_on(rule, _d("2026-09-16")) is True
|
||||
assert occurs_on(rule, _d("2026-09-23")) is False
|
||||
assert occurs_on(rule, _d("2026-09-30")) is True
|
||||
assert occurs_on(rule, _d("2026-10-07")) is False
|
||||
assert occurs_on(rule, _d("2026-10-14")) is True
|
||||
|
||||
|
||||
def test_custom_days_interval_1_behaves_like_weekly() -> None:
|
||||
"""CUSTOM_DAYS with interval 1 behaves identically to WEEKLY."""
|
||||
weekly = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1, 3, 5},
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
custom = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 5},
|
||||
start_date="2026-09-01",
|
||||
)
|
||||
# Whole month of September must match exactly.
|
||||
for day in range(1, 31):
|
||||
date = datetime.date(2026, 9, day)
|
||||
assert occurs_on(weekly, date) == occurs_on(custom, date), date
|
||||
|
||||
|
||||
def test_custom_days_never_fires_before_start(tmp_path) -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={0},
|
||||
start_date="2026-09-07", # a Monday
|
||||
)
|
||||
assert occurs_on(rule, _d("2026-09-06")) is False # Sun, before
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True # Mon, on
|
||||
|
||||
|
||||
def test_hand_checked_8_week_sequence_weekly_mon_wed_fri() -> None:
|
||||
"""The done-condition's hand-checked sequence: 8 weeks of a
|
||||
Mon/Wed/Fri rule anchored at Tue 2026-09-01, interval 1.
|
||||
|
||||
Anchor weeks from Tue Sep 1 (week 0 = Sep 1-7, week 1 = Sep 8-14,
|
||||
... week 7 = Oct 20-26). Mon/Wed/Fri firings (interval 1, so every
|
||||
in-set weekday fires regardless of week):
|
||||
Week 0 (Sep 1-7): Wed 2, Fri 4 (Mon Sep 7 is ALSO week 0:
|
||||
6 elapsed days // 7 = 0 — the anchor week
|
||||
boundary sits on Sep 8, not on the Monday)
|
||||
Week 1 (Sep 8-14): Wed 9, Fri 11, Mon 14 (13 elapsed days // 7 = 1)
|
||||
Week 2 (Sep 15-21): Wed 16, Fri 18, Mon 21
|
||||
Week 3 (Sep 22-28): Wed 23, Fri 25, Mon 28
|
||||
Week 4 (Sep 29-Oct 5): Wed 30, Fri 2 (Oct), Mon 5 (Oct)
|
||||
Week 5 (Oct 6-12): Wed 7, Fri 9, Mon 12
|
||||
Week 6 (Oct 13-19): Wed 14, Fri 16, Mon 19
|
||||
Week 7 (Oct 20-26): Wed 21, Fri 23, Mon 26
|
||||
"""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0, 2, 4},
|
||||
start_date="2026-09-01", end_date="2026-10-26",
|
||||
)
|
||||
expected = [
|
||||
"2026-09-02", "2026-09-04",
|
||||
"2026-09-07", "2026-09-09", "2026-09-11",
|
||||
"2026-09-14", "2026-09-16", "2026-09-18",
|
||||
"2026-09-21", "2026-09-23", "2026-09-25",
|
||||
"2026-09-28", "2026-09-30", "2026-10-02",
|
||||
"2026-10-05", "2026-10-07", "2026-10-09",
|
||||
"2026-10-12", "2026-10-14", "2026-10-16",
|
||||
"2026-10-19", "2026-10-21", "2026-10-23", "2026-10-26",
|
||||
]
|
||||
actual = occurrences_between(rule, "2026-09-01", "2026-10-26")
|
||||
assert actual == expected, (
|
||||
f"diff: {set(expected) ^ set(actual)}"
|
||||
)
|
||||
|
||||
|
||||
def test_hand_checked_8_week_sequence_weekly_interval_2_mondays() -> None:
|
||||
"""8 weeks of a Monday rule at interval 2, anchored Mon 2026-09-07
|
||||
(week 0 = Sep 7-13): fires in weeks 0, 2, 4, 6 -> Mon Sep 7, Mon Sep 21,
|
||||
Mon Oct 5, Mon Oct 19."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
|
||||
start_date="2026-09-07", end_date="2026-10-31",
|
||||
)
|
||||
actual = occurrences_between(rule, "2026-09-07", "2026-10-31")
|
||||
assert actual == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
|
||||
|
||||
|
||||
def test_custom_days_hand_checked_8_week_sequence() -> None:
|
||||
"""8 weeks of a Tue/Thu/Sun rule (1,3,6) anchored Mon 2026-09-07:
|
||||
fires Tue 8, Thu 10, Sun 13, then weekly."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.CUSTOM_DAYS, weekday_set={1, 3, 6},
|
||||
start_date="2026-09-07",
|
||||
)
|
||||
# Anchor weeks from Mon Sep 7 (w0 = Sep 7-13 ... w7 = Oct 26-Nov 1):
|
||||
# Tue/Thu/Sun firings per week, 8 weeks fully covered.
|
||||
actual = occurrences_between(rule, "2026-09-07", "2026-11-01")
|
||||
assert actual == [
|
||||
"2026-09-08", "2026-09-10", "2026-09-13",
|
||||
"2026-09-15", "2026-09-17", "2026-09-20",
|
||||
"2026-09-22", "2026-09-24", "2026-09-27",
|
||||
"2026-09-29", "2026-10-01", "2026-10-04",
|
||||
"2026-10-06", "2026-10-08", "2026-10-11",
|
||||
"2026-10-13", "2026-10-15", "2026-10-18",
|
||||
"2026-10-20", "2026-10-22", "2026-10-25",
|
||||
"2026-10-27", "2026-10-29", "2026-11-01",
|
||||
]
|
||||
|
||||
|
||||
def test_weekly_survives_53_week_year_boundary() -> None:
|
||||
"""Anchor arithmetic never flips at Jan 1 (the ISO parity trap):
|
||||
a weekly rule spanning New Year keeps its weekday pattern."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={3}, # Thursdays
|
||||
start_date="2026-12-24", # a Thursday
|
||||
)
|
||||
# Thursdays around New Year 2026->2027: Dec 24, 31, Jan 7, 14.
|
||||
assert occurs_on(rule, _d("2026-12-24")) is True
|
||||
assert occurs_on(rule, _d("2026-12-31")) is True
|
||||
assert occurs_on(rule, _d("2027-01-07")) is True
|
||||
assert occurs_on(rule, _d("2027-01-14")) is True
|
||||
# Interleaved days are not Thursdays.
|
||||
assert occurs_on(rule, _d("2027-01-04")) is False # Monday
|
||||
assert occurs_on(rule, _d("2027-01-05")) is False # Tuesday
|
||||
|
||||
|
||||
def test_weekly_interval_2_across_year_boundary() -> None:
|
||||
"""Interval weeks continue across Jan 1 with no parity flip."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={4}, interval=2,
|
||||
start_date="2026-12-25", # a Friday
|
||||
)
|
||||
# Weeks: Dec 25 (w0) fires, Jan 1 (w1) skipped, Jan 8 (w2) fires,
|
||||
# Jan 15 (w3) skipped, Jan 22 (w4) fires.
|
||||
assert occurs_on(rule, _d("2026-12-25")) is True
|
||||
assert occurs_on(rule, _d("2027-01-01")) is False
|
||||
assert occurs_on(rule, _d("2027-01-08")) is True
|
||||
assert occurs_on(rule, _d("2027-01-15")) is False
|
||||
assert occurs_on(rule, _d("2027-01-22")) is True
|
||||
|
||||
|
||||
def test_occurrences_between_weekly_disjoint(tmp_path) -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={1},
|
||||
start_date="2026-09-01", end_date="2026-09-30",
|
||||
)
|
||||
assert occurrences_between(rule, "2026-10-01", "2026-10-31") == []
|
||||
|
||||
|
||||
def test_occurrences_between_weekly_interval_2_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
|
||||
start_date="2026-09-07", end_date="2026-10-31",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-09-01", "2026-10-31")
|
||||
assert result == ["2026-09-07", "2026-09-21", "2026-10-05", "2026-10-19"]
|
||||
|
||||
|
||||
def test_occurrences_between_rejects_inverted_bounds() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, start_date="2026-09-01"
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2026-10-10", "2026-09-01")
|
||||
|
||||
def test_weekly_interval_2_non_monday_anchor_boundary() -> None:
|
||||
"""The anchor week is anchored at start_date, NOT a calendar week:
|
||||
a Tuesday-anchored Monday rule fires on Mon Sep 7 (week 0 extends
|
||||
through Sep 7), skips Mon Sep 14 (week 1), fires Mon Sep 21 (week 2)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.WEEKLY, weekday_set={0}, interval=2,
|
||||
start_date="2026-09-01", # a TUESDAY anchor
|
||||
)
|
||||
# Sep 7 - Sep 1 = 6 days -> week 0 (even): fires.
|
||||
assert occurs_on(rule, _d("2026-09-07")) is True
|
||||
# Sep 14: 13 days -> week 1 (odd): skipped.
|
||||
assert occurs_on(rule, _d("2026-09-14")) is False
|
||||
# Sep 21: 20 days -> week 2 (even): fires.
|
||||
assert occurs_on(rule, _d("2026-09-21")) is True
|
||||
# Sep 28: 27 days -> week 3 (odd): skipped.
|
||||
assert occurs_on(rule, _d("2026-09-28")) is False
|
||||
# Oct 5: 34 days -> week 4 (even): fires.
|
||||
assert occurs_on(rule, _d("2026-10-05")) is True
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for YEARLY recurrence with the leap-day clamp policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.nestquest.recurrence import (
|
||||
RuleType,
|
||||
RuleValidationError,
|
||||
ScheduleRule,
|
||||
occurs_on,
|
||||
occurrences_between,
|
||||
)
|
||||
|
||||
|
||||
def _d(date: str) -> datetime.date:
|
||||
return datetime.date.fromisoformat(date)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nominal-day firings across four consecutive years (incl. leap 2028)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_june_15_four_consecutive_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
for year in (2026, 2027, 2028, 2029):
|
||||
assert occurs_on(rule, datetime.date(year, 6, 15)) is True, year
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 14)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 16)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 7, 15)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_15_across_four_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=15,
|
||||
start_date="2026-02-15",
|
||||
)
|
||||
# Feb 15 exists every year; the leap day is irrelevant.
|
||||
for year in (2026, 2027, 2028, 2029):
|
||||
assert occurs_on(rule, datetime.date(year, 2, 15)) is True, year
|
||||
|
||||
|
||||
def test_yearly_never_fires_before_start() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2027-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 15)) is True
|
||||
|
||||
|
||||
def test_yearly_respects_end_date() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15", end_date="2028-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True
|
||||
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leap-day policy: Feb 29 rule fires Feb 28 in non-leap years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_feb_29_clamps_to_feb_28_non_leap() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
# 2026 non-leap: no Feb 29 — the clamp fires Feb 28.
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 27)) is False
|
||||
# 2027 non-leap: same clamp.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
|
||||
|
||||
def test_yearly_feb_29_fires_feb_29_in_leap_years() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
# 2028 leap: Feb 29 exists — nominal day.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_29_four_consecutive_years() -> None:
|
||||
"""The done-condition's four-year sweep: 2026 (clamp 28), 2027
|
||||
(clamp 28), 2028 (leap: nominal 29), 2029 (clamp 28)."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2029, 2, 28)) is True
|
||||
# No other day in those Februaries fires.
|
||||
for year, non_firing in (
|
||||
(2026, (27,)), (2027, (26, 27)), (2028, (27,)),
|
||||
(2029, (26, 27)),
|
||||
):
|
||||
for day in non_firing:
|
||||
assert occurs_on(rule, datetime.date(year, 2, day)) is False
|
||||
|
||||
|
||||
def test_yearly_feb_30_is_impossible_by_model() -> None:
|
||||
"""The model rejects day_of_month 30+ for February? No — 30/31 are
|
||||
accepted (1..31) and clamp to the month end like MONTHLY_DAY."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=30,
|
||||
start_date="2027-02-28",
|
||||
)
|
||||
# Non-leap 2027: Feb 28 is the month end — the clamp fires it.
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is True
|
||||
# Leap 2028: Feb 29 is the month end — clamps there.
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 28)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Day-less YEARLY rules fire on the first of the month
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_without_day_fires_on_first_of_month() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, start_date="2026-01-01",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 1)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 2)) is False
|
||||
assert occurs_on(rule, datetime.date(2026, 5, 1)) is False
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 1)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interval: every N years
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_interval_2_every_second_year() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15, interval=2,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 15)) is True # y0
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 15)) is False # y1
|
||||
assert occurs_on(rule, datetime.date(2028, 6, 15)) is True # y2
|
||||
assert occurs_on(rule, datetime.date(2029, 6, 15)) is False # y3
|
||||
assert occurs_on(rule, datetime.date(2030, 6, 15)) is True # y4
|
||||
|
||||
|
||||
def test_yearly_interval_2_leap_day_every_second_year() -> None:
|
||||
"""A Feb 29 rule at interval 2 anchored 2026 fires in even years:
|
||||
2026 clamps (Feb 28), 2027 skips, 2028 hits the nominal Feb 29."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29, interval=2,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 2, 28)) is True # y0 clamp
|
||||
assert occurs_on(rule, datetime.date(2027, 2, 28)) is False # y1
|
||||
assert occurs_on(rule, datetime.date(2028, 2, 29)) is True # y2 nominal
|
||||
assert occurs_on(rule, datetime.date(2029, 2, 28)) is False # y3
|
||||
assert occurs_on(rule, datetime.date(2030, 2, 28)) is True # y4 clamp
|
||||
|
||||
|
||||
def test_yearly_interval_3_across_start_year() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=3, day_of_month=10, interval=3,
|
||||
start_date="2026-03-10",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 3, 10)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 3, 10)) is False
|
||||
assert occurs_on(rule, datetime.date(2028, 3, 10)) is False
|
||||
assert occurs_on(rule, datetime.date(2029, 3, 10)) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# occurrences_between
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_span() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
result = occurrences_between(rule, "2026-01-01", "2029-12-31")
|
||||
assert result == [
|
||||
"2026-02-28", "2027-02-28", "2028-02-29", "2029-02-28",
|
||||
]
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_disjoint_range() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=15,
|
||||
start_date="2026-06-15",
|
||||
)
|
||||
# The rule only ever fires in June: other months are empty.
|
||||
assert occurrences_between(rule, "2027-01-01", "2027-05-31") == []
|
||||
assert occurrences_between(rule, "2027-07-01", "2027-12-31") == []
|
||||
|
||||
|
||||
def test_yearly_occurrences_between_rejects_inverted() -> None:
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=2, day_of_month=29,
|
||||
start_date="2026-02-28",
|
||||
)
|
||||
with pytest.raises(RuleValidationError, match="on or after"):
|
||||
occurrences_between(rule, "2029-01-01", "2026-01-01")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Day of month 31 in a 30-day month: clamp consistency with MONTHLY_DAY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_yearly_june_31_clamps_to_june_30() -> None:
|
||||
"""June has 30 days: a June 31st yearly rule clamps to June 30."""
|
||||
rule = ScheduleRule(
|
||||
rule_type=RuleType.YEARLY, month=6, day_of_month=31,
|
||||
start_date="2026-06-30",
|
||||
)
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2027, 6, 30)) is True
|
||||
assert occurs_on(rule, datetime.date(2026, 6, 29)) is False
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for store.py of NestQuest."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from custom_components.nestquest import store
|
||||
from custom_components.nestquest.const import SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _make_hass(config_dir: Path, execute: bool = True):
|
||||
hass = MagicMock()
|
||||
hass.config.path = MagicMock(return_value=str(config_dir / SQLITE_DB_FILENAME))
|
||||
hass.async_add_executor_job = AsyncMock(
|
||||
side_effect=(lambda fn, *a: fn(*a)) if execute else None
|
||||
)
|
||||
return hass
|
||||
|
||||
|
||||
def test_returns_absolute_path_from_config_path() -> None:
|
||||
hass = _make_hass(Path("/config"), execute=False)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME)
|
||||
assert db_path == Path("/config") / SQLITE_DB_FILENAME
|
||||
assert db_path.is_absolute()
|
||||
|
||||
|
||||
def test_relative_config_path_is_normalized_to_absolute(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
hass = MagicMock()
|
||||
hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME)
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
assert db_path.is_absolute()
|
||||
assert db_path.name == SQLITE_DB_FILENAME
|
||||
assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME)
|
||||
assert db_path.parent.is_dir()
|
||||
assert (tmp_path / "relative-config").is_dir()
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_creates_missing_parent_directory(tmp_path) -> None:
|
||||
nested = tmp_path / "homeassistant" / "config"
|
||||
hass = _make_hass(nested)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
assert db_path.parent == nested
|
||||
assert nested.is_dir()
|
||||
assert db_path.parent.is_dir()
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_two_calls_return_same_path(tmp_path) -> None:
|
||||
hass = _make_hass(tmp_path)
|
||||
first = _run(store.async_get_db_path(hass))
|
||||
second = _run(store.async_get_db_path(hass))
|
||||
assert first == second
|
||||
assert first.parent.is_dir()
|
||||
assert not first.exists()
|
||||
assert not second.exists()
|
||||
|
||||
|
||||
def test_existing_directory_does_not_fail(tmp_path) -> None:
|
||||
nested = tmp_path / "config"
|
||||
nested.mkdir()
|
||||
hass = _make_hass(nested)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
assert db_path == nested / SQLITE_DB_FILENAME
|
||||
assert db_path.parent.is_dir()
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_mkdir_runs_via_executor(tmp_path) -> None:
|
||||
nested = tmp_path / "deep" / "nested"
|
||||
hass = _make_hass(nested)
|
||||
db_path = _run(store.async_get_db_path(hass))
|
||||
assert nested.is_dir()
|
||||
hass.async_add_executor_job.assert_called_once()
|
||||
args, kwargs = hass.async_add_executor_job.call_args
|
||||
fn, fn_args = args[0], args[1:]
|
||||
assert callable(fn)
|
||||
assert fn is store._ensure_dir
|
||||
assert fn_args == (nested,)
|
||||
assert kwargs == {}
|
||||
assert db_path.parent == nested
|
||||
assert db_path.parent.is_dir()
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_executor_helper_creates_directory(tmp_path) -> None:
|
||||
nested = tmp_path / "executor" / "made"
|
||||
store._ensure_dir(nested)
|
||||
assert nested.is_dir()
|
||||
store._ensure_dir(nested)
|
||||
assert nested.is_dir()
|
||||
|
||||
|
||||
def test_no_sqlite3_import_in_package() -> None:
|
||||
pkg_dir = Path(store.__file__).parent
|
||||
py_files = list(pkg_dir.glob("*.py"))
|
||||
assert py_files
|
||||
for py_file in py_files:
|
||||
tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert alias.name != "sqlite3", (
|
||||
f"Found sqlite3 import in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module == "sqlite3":
|
||||
raise AssertionError(
|
||||
f"Found sqlite3 import in {py_file.name}:{node.lineno}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_file_creation_calls_in_store() -> None:
|
||||
source = Path(store.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(store.__file__))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "open":
|
||||
raise AssertionError(
|
||||
f"Found open() call in store.py:{node.lineno}"
|
||||
)
|
||||
if (
|
||||
isinstance(func, ast.Attribute)
|
||||
and func.attr == "touch"
|
||||
):
|
||||
raise AssertionError(
|
||||
f"Found touch() call in store.py:{node.lineno}"
|
||||
)
|
||||
assert "touch(" not in source
|
||||
assert "open(" not in source
|
||||
|
||||
|
||||
def test_container_style_path_resolves() -> None:
|
||||
hass = _make_hass(Path("/config"), execute=False)
|
||||
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
def test_core_style_path_resolves(tmp_path) -> None:
|
||||
config_dir = tmp_path / ".homeassistant"
|
||||
hass = _make_hass(config_dir)
|
||||
assert _run(store.async_get_db_path(hass)) == config_dir / SQLITE_DB_FILENAME
|
||||
assert (config_dir / SQLITE_DB_FILENAME).parent.is_dir()
|
||||
@@ -0,0 +1,116 @@
|
||||
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