Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15a02fb48 | ||
|
|
d75ddc19bd | ||
|
|
33102f8806 | ||
|
|
345bd200e1 |
@@ -18,16 +18,13 @@ from homeassistant.helpers.event import async_track_time_change
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
@@ -240,7 +237,7 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
remove_time_change_listener: Callable[[], Any]
|
||||
@@ -269,38 +266,10 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
return owners
|
||||
|
||||
|
||||
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
|
||||
"""Return the (hour, minute) the daily rollover fires at from ``options``.
|
||||
|
||||
The options flow validates ``day_rollover_time`` as a strict ``HH:MM``
|
||||
string, so a stored value never needs re-validation here; a missing key
|
||||
falls back to :data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
|
||||
"""
|
||||
rollover = options.get(CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME)
|
||||
hour, _, minute = rollover.partition(":")
|
||||
return int(hour), int(minute)
|
||||
|
||||
|
||||
def _configured_horizon_days(options: dict[str, Any]) -> int:
|
||||
"""Return the configured generation horizon, validated, else the default.
|
||||
|
||||
The options flow already validates ``horizon_days`` as an integer >= 1,
|
||||
but a stored ``entry.options`` dict could still carry a partial or
|
||||
hand-edited value. Re-checking here — and falling back to
|
||||
:data:`~.const.DEFAULT_HORIZON_DAYS` on a missing, non-int or sub-1 value
|
||||
— keeps a malformed option from shrinking or exploding the materialized
|
||||
horizon.
|
||||
"""
|
||||
value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
return DEFAULT_HORIZON_DAYS
|
||||
return value
|
||||
|
||||
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
|
||||
|
||||
@@ -311,23 +280,22 @@ async def _run_horizon_materialization(
|
||||
|
||||
This is the ONE generation path: the startup backfill, the daily rollover
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
no duplicated materialization logic. ``horizon_days`` sizes the window
|
||||
and defaults to :data:`~.const.DEFAULT_HORIZON_DAYS`; callers thread the
|
||||
entry's validated option value through.
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
start_date = today.isoformat()
|
||||
end_date = (
|
||||
today + datetime.timedelta(days=horizon_days)
|
||||
).isoformat()
|
||||
await _materialize_run(database, start_date, end_date, today=today)
|
||||
start, end = settings.horizon_window(today)
|
||||
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
|
||||
|
||||
|
||||
def _register_day_rollover_listener(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
options: dict[str, Any],
|
||||
settings: NestQuestSettings,
|
||||
) -> Callable[[], Any]:
|
||||
"""Register the daily rollover listener and return its remover.
|
||||
|
||||
@@ -335,16 +303,15 @@ def _register_day_rollover_listener(
|
||||
first re-materializes the rolling horizon
|
||||
``[today, today + horizon_days]`` through the shared
|
||||
:func:`_run_horizon_materialization` path (the entry's configured,
|
||||
validated ``horizon_days``), then runs the Feature 11 missed sweep
|
||||
— announcing yesterday's still-open quests as missed AFTER today's
|
||||
instances exist, watermark-guarded so a re-fire of the listener the
|
||||
same night announces nothing twice.
|
||||
validated ``horizon_days`` carried on ``settings``), then runs the
|
||||
Feature 11 missed sweep — announcing yesterday's still-open quests
|
||||
as missed AFTER today's instances exist, watermark-guarded so a
|
||||
re-fire of the listener the same night announces nothing twice.
|
||||
"""
|
||||
hour, minute = _day_rollover_hour_minute(options)
|
||||
horizon_days = _configured_horizon_days(options)
|
||||
hour, minute = settings.day_rollover_hour_minute
|
||||
|
||||
async def _run_materialization(_now: datetime.datetime) -> None:
|
||||
await _run_horizon_materialization(hass, database, horizon_days)
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
await run_missed_sweep(hass, database)
|
||||
|
||||
return async_track_time_change(
|
||||
@@ -359,7 +326,9 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
|
||||
their own; they resolve whichever config entry's runtime data is
|
||||
currently live at call time, so none of them can hold a stale handle
|
||||
to a closed connection after another entry unloads. The record also
|
||||
carries that entry's options (e.g. the configured horizon).
|
||||
carries that entry's validated ``settings`` (e.g. the configured
|
||||
horizon) so service handlers read horizon days off it rather than
|
||||
re-reading HA config.
|
||||
"""
|
||||
for runtime_data in hass.data.get(DOMAIN, {}).values():
|
||||
database = getattr(runtime_data, "database", None)
|
||||
@@ -379,9 +348,8 @@ def _make_regenerate_handler(hass: HomeAssistant):
|
||||
"live database; ignoring"
|
||||
)
|
||||
return
|
||||
horizon_days = _configured_horizon_days(runtime_data.options)
|
||||
await _run_horizon_materialization(
|
||||
hass, runtime_data.database, horizon_days
|
||||
hass, runtime_data.database, runtime_data.settings
|
||||
)
|
||||
|
||||
return _regenerate
|
||||
@@ -495,18 +463,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
context_user_id=context_user_id,
|
||||
owner_ids=owner_ids,
|
||||
)
|
||||
# Build the explicit settings object from entry.options ONCE: the
|
||||
# horizon materialization and the daily-rollover listener both
|
||||
# consume it, so core never reads HA config itself. A malformed
|
||||
# stored option falls back PER FIELD (via from_options_resilient)
|
||||
# so a single bad sibling option cannot reset the user's
|
||||
# horizon_days or day_rollover_time — the two fields core
|
||||
# consumes — to their defaults.
|
||||
settings = NestQuestSettings.from_options_resilient(entry.options)
|
||||
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
||||
remove_time_change_listener = _register_day_rollover_listener(
|
||||
hass, database, dict(entry.options)
|
||||
hass, database, settings
|
||||
)
|
||||
# Backfill any days the daily listener missed while the integration
|
||||
# was off (or freshly installed): run the one idempotent walk over
|
||||
# [today, today + horizon_days] once at setup, using the entry's
|
||||
# configured horizon. This runs through the SAME path as the daily
|
||||
# listener and regenerate service.
|
||||
await _run_horizon_materialization(
|
||||
hass, database, _configured_horizon_days(dict(entry.options))
|
||||
)
|
||||
await _run_horizon_materialization(hass, database, settings)
|
||||
# The missed sweep runs at startup too (Feature 11): if HA was
|
||||
# down at the rollover time, the previous night's still-open
|
||||
# quests are announced as missed now instead of waiting for
|
||||
@@ -529,7 +503,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
remove_time_change_listener=remove_time_change_listener,
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
DOMAIN = "nestquest"
|
||||
LOGGER_NAME = "custom_components.nestquest"
|
||||
LOGGER = logging.getLogger(LOGGER_NAME)
|
||||
|
||||
#: Strict 24-hour ``HH:MM`` pattern shared by the options flow and the
|
||||
#: settings object's time validation, so a stored option and a freshly
|
||||
#: validated form submit parse the same way. Defined here (HA-free) so
|
||||
#: both the integration's options flow and the core settings module
|
||||
#: import ONE pattern instead of re-copying it.
|
||||
TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d")
|
||||
|
||||
# Platforms set up per config entry (Feature 10): the per-child day
|
||||
# sensors and binary sensors.
|
||||
PLATFORMS: list[str] = ["sensor", "binary_sensor"]
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Explicit, Home-Assistant-free settings object for NestQuest core.
|
||||
|
||||
The integration reads its configuration off a Home Assistant config
|
||||
entry (``entry.options``); the core package must not. This module
|
||||
bridges that split: it defines a frozen, validation-carrying
|
||||
:class:`NestQuestSettings` dataclass that the integration constructs
|
||||
from ``entry.options`` (via :meth:`NestQuestSettings.from_options`, or
|
||||
:meth:`NestQuestSettings.from_options_resilient` for the per-field
|
||||
fallback used at setup) and then passes into the core paths that
|
||||
previously read HA config themselves — the rolling horizon
|
||||
(``horizon_days``) and the daily rollover time (``day_rollover_time``),
|
||||
plus the Feature 11 notification settings (notify target, the three
|
||||
summary/reminder/report times, and the four independent enable toggles).
|
||||
|
||||
Nothing in this module imports ``homeassistant`` or touches a config
|
||||
entry; ``from_options`` accepts any plain mapping keyed by the
|
||||
:data:`~.const.CONF_*` names. The validation semantics match the
|
||||
options flow's: ``horizon_days`` must be a real integer >= 1 (bool
|
||||
rejected), and ``day_rollover_time`` plus the three notification times
|
||||
must be strict ``HH:MM`` 24-hour strings. Construction therefore can
|
||||
never yield a settings object whose horizon could shrink or explode the
|
||||
materialized window, or whose rollover fires at an unparsable time.
|
||||
|
||||
The strict resolvers (:func:`_resolve_horizon_days`, :func:`_validate_hhmm`,
|
||||
:func:`_resolve_bool`, :func:`_resolve_optional_str`) reject ``None``; the
|
||||
``None``-as-absent mapping lives ONLY in :meth:`from_options` /
|
||||
:meth:`from_options_resilient` (via :func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings` cannot smuggle a ``None`` past
|
||||
:meth:`__post_init__`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
|
||||
|
||||
def _option_value(
|
||||
opts: Mapping[str, Any], key: str, default: Any
|
||||
) -> Any:
|
||||
"""Return ``opts[key]`` when present and non-None, else ``default``.
|
||||
|
||||
This is the SOLE place the ``None``-as-absent mapping lives: a stored
|
||||
``None`` is treated the same as an absent key — the historical
|
||||
``horizon_days`` path mapped a missing-or-None value to the default,
|
||||
and this keeps that forgiving semantics uniform across every field.
|
||||
The per-field resolvers (:func:`_resolve_horizon_days`,
|
||||
:func:`_validate_hhmm`, :func:`_resolve_bool`,
|
||||
:func:`_resolve_optional_str`) are STRICT — they reject ``None`` —
|
||||
so a partially-populated ``entry.options`` dict (or one migrated
|
||||
across versions) never raises just because a key carries ``None``,
|
||||
and a hand-built :class:`NestQuestSettings` cannot smuggle a
|
||||
``None`` past :meth:`NestQuestSettings.__post_init__`.
|
||||
"""
|
||||
if key not in opts:
|
||||
return default
|
||||
value = opts[key]
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _validate_hhmm(value: Any, *, field: str) -> str:
|
||||
"""Validate a strict ``HH:MM`` string and return it; raise naming ``field``.
|
||||
|
||||
Uses the shared :data:`~.const.TIME_PATTERN` (the same compiled
|
||||
regex the options flow applies), so a stored option and a freshly
|
||||
validated form submit parse the same way. ``None`` (and any
|
||||
non-string) is rejected; the ``None``-as-absent mapping lives ONLY
|
||||
in :meth:`NestQuestSettings.from_options` (via :func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, str) or TIME_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError(
|
||||
f"{field} must be a strict HH:MM string, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_horizon_days(value: Any) -> int:
|
||||
"""Validate a horizon value and return the resolved day count.
|
||||
|
||||
A bool is rejected (SQLite binds ``True``/``False`` onto an int), a
|
||||
non-int is rejected, and a sub-1 value is rejected — so a malformed
|
||||
option can never shrink or explode the materialized horizon.
|
||||
``None`` is rejected here too; the ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`), so a hand-built
|
||||
:class:`NestQuestSettings(horizon_days=None)` raises in
|
||||
:meth:`NestQuestSettings.__post_init__` rather than silently
|
||||
constructing an object whose :meth:`NestQuestSettings.horizon_window`
|
||||
would later raise ``TypeError`` on ``timedelta(days=None)``.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise ValueError(
|
||||
f"horizon_days must be a positive integer, got {value!r}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_bool(value: Any) -> bool:
|
||||
"""Return a real bool; a non-bool (including ``None``) is rejected.
|
||||
|
||||
voluptuous ``bool`` would coerce strings, so the options flow uses a
|
||||
strict-bool validator and this mirrors it. The ``None``-as-absent
|
||||
mapping lives ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`).
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"expected a boolean, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_optional_str(value: Any) -> str:
|
||||
"""Return a stripped notify target string; a non-str is rejected.
|
||||
|
||||
A padded or whitespace-only value is STRIPPED to its trim — the
|
||||
shipped default is the empty string, meaning "no notifications are
|
||||
configured yet". This is intentionally MORE LENIENT than the options
|
||||
flow, which REJECTS a target that differs from its own trim (so a
|
||||
padded value never persists): the integration tolerates a stored
|
||||
padded value (e.g. one migrated from an older schema) by normalizing
|
||||
it rather than crashing setup. The ``None``-as-absent mapping lives
|
||||
ONLY in :meth:`NestQuestSettings.from_options` (via
|
||||
:func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
|
||||
applies this SAME normalizer so a hand-built
|
||||
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
|
||||
exactly like the options-flow path (no padded value can slip through
|
||||
direct construction either).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"notify_target must be a string, got {value!r}")
|
||||
return value.strip()
|
||||
|
||||
|
||||
#: The single field table the two builders share. Each row is
|
||||
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
|
||||
#: applies every validator (raising on the first invalid value) while
|
||||
#: ``from_options_resilient`` wraps each in its own try/except so a single
|
||||
#: bad sibling falls back to its default alone. Driving both builders
|
||||
#: from this table avoids three-way drift between ``__post_init__``'s
|
||||
#: inline checks and the two option builders' field lists: the only
|
||||
#: places that enumerate the fields are this table and
|
||||
#: ``__post_init__`` (which validates a hand-built instance without the
|
||||
#: None->default mapping, so it stays inline).
|
||||
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
|
||||
(
|
||||
"day_rollover_time",
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="day_rollover_time"),
|
||||
),
|
||||
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
|
||||
(
|
||||
"morning_summary_time",
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
lambda v: _validate_hhmm(v, field="morning_summary_time"),
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_time",
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
|
||||
),
|
||||
(
|
||||
"end_of_day_report_time",
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
|
||||
),
|
||||
(
|
||||
"morning_summary_enabled",
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"afternoon_reminder_enabled",
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"end_of_day_report_enabled",
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
(
|
||||
"celebration_enabled",
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
_resolve_bool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NestQuestSettings:
|
||||
"""Explicit, HA-free configuration carried into core paths.
|
||||
|
||||
The integration builds this once from ``entry.options`` (via
|
||||
:meth:`from_options`, or :meth:`from_options_resilient` for the
|
||||
per-field fallback used at setup) and threads it into the horizon
|
||||
materialization and the daily-rollover scheduling. Core never
|
||||
reads HA config itself; callers pass the resolved horizon and
|
||||
rollover time in.
|
||||
|
||||
All fields are validated at construction; the dataclass is frozen so
|
||||
a stale settings object cannot be mutated in place after the
|
||||
integration hands it off.
|
||||
"""
|
||||
|
||||
horizon_days: int = DEFAULT_HORIZON_DAYS
|
||||
day_rollover_time: str = DEFAULT_DAY_ROLLOVER_TIME
|
||||
notify_target: str = ""
|
||||
morning_summary_time: str = DEFAULT_MORNING_SUMMARY_TIME
|
||||
afternoon_reminder_time: str = DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
end_of_day_report_time: str = DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
morning_summary_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
afternoon_reminder_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
# cannot carry an invalid value: the same rules from_options
|
||||
# applies, but WITHOUT the None->default mapping (that lives
|
||||
# only in from_options via _option_value). A None horizon, time,
|
||||
# or toggle raises here rather than later blowing up
|
||||
# horizon_window or the rollover listener.
|
||||
_resolve_horizon_days(self.horizon_days)
|
||||
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
||||
# ``notify_target`` is normalised the SAME way as from_options
|
||||
# (via _resolve_optional_str): a non-str is rejected and a
|
||||
# padded/whitespace-only value is STRIPPED to its trim. This
|
||||
# keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
|
||||
# behaving like the options flow (which strips on persist) so a
|
||||
# padded value cannot slip through direct construction either.
|
||||
# The dataclass is frozen, so the stripped value is written via
|
||||
# object.__setattr__.
|
||||
stripped_notify_target = _resolve_optional_str(self.notify_target)
|
||||
object.__setattr__(self, "notify_target", stripped_notify_target)
|
||||
_validate_hhmm(self.morning_summary_time, field="morning_summary_time")
|
||||
_validate_hhmm(
|
||||
self.afternoon_reminder_time, field="afternoon_reminder_time"
|
||||
)
|
||||
_validate_hhmm(
|
||||
self.end_of_day_report_time, field="end_of_day_report_time"
|
||||
)
|
||||
for name in (
|
||||
"morning_summary_enabled",
|
||||
"afternoon_reminder_enabled",
|
||||
"end_of_day_report_enabled",
|
||||
"celebration_enabled",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"{name} must be a boolean, got {value!r}"
|
||||
)
|
||||
|
||||
@property
|
||||
def day_rollover_hour_minute(self) -> tuple[int, int]:
|
||||
"""The (hour, minute) the daily rollover fires at."""
|
||||
# The string is validated at construction, so a plain partition
|
||||
# is safe and avoids re-running the regex on every access.
|
||||
hour_str, _, minute_str = self.day_rollover_time.partition(":")
|
||||
return int(hour_str), int(minute_str)
|
||||
|
||||
def horizon_window(
|
||||
self, today: datetime.date
|
||||
) -> tuple[datetime.date, datetime.date]:
|
||||
"""Return the rolling horizon window ``[today, today + horizon_days]``.
|
||||
|
||||
``today`` is REQUIRED (there is no host-clock default): the
|
||||
integration passes the HA-local ``hass.config.time_zone`` date so
|
||||
the horizon tracks the household's own day, and a silent
|
||||
host-clock fallback would be a wrong-day footgun (a test or a
|
||||
caller that forgets to pass ``today`` would otherwise materialize
|
||||
against the machine's calendar, not the household's). The
|
||||
returned window is closed/inclusive — the same shape
|
||||
:func:`~.materialize.materialize` walks.
|
||||
"""
|
||||
end = today + datetime.timedelta(days=self.horizon_days)
|
||||
return today, end
|
||||
|
||||
@classmethod
|
||||
def from_options(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings from a CONF_*-keyed mapping (e.g. ``entry.options``).
|
||||
|
||||
Reads the same keys the options flow persists, applying the
|
||||
matching defaults when a key is absent OR ``None`` (via
|
||||
:func:`_option_value`, the SOLE place the ``None``-as-absent
|
||||
mapping lives) and validating STRICTLY: ``horizon_days`` is a
|
||||
positive int (bool rejected), the four times are strict
|
||||
``HH:MM``, the four toggles are real booleans, and
|
||||
``notify_target`` is a string (stripped). A malformed value
|
||||
RAISES — the integration uses :meth:`from_options_resilient` at
|
||||
setup to fall back per field instead of crashing. A ``None``
|
||||
mapping yields the all-defaults settings — useful for tests and
|
||||
for a fresh install whose options dict is empty.
|
||||
|
||||
``options`` is consumed read-only via
|
||||
:class:`types.MappingProxyType`, so this method never mutates
|
||||
the caller's mapping.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
return cls(
|
||||
**{
|
||||
field: validator(_option_value(opts, key, default))
|
||||
for field, key, default, validator in _FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_options_resilient(
|
||||
cls, options: Mapping[str, Any] | None = None
|
||||
) -> "NestQuestSettings":
|
||||
"""Build settings, falling back PER FIELD on a malformed value.
|
||||
|
||||
Unlike :meth:`from_options` (which raises on the first invalid
|
||||
field), this resolves EACH field in its own try/except: a
|
||||
malformed value logs a :data:`~.const.LOGGER` warning naming the
|
||||
field and falls back to that field's default. The integration
|
||||
uses this at setup so a single bad sibling option (e.g. a
|
||||
migrated ``morning_summary_time`` of ``'25:00'``) cannot
|
||||
silently reset the user's ``horizon_days`` or
|
||||
``day_rollover_time`` — the two fields core actually consumes.
|
||||
|
||||
An absent or ``None`` key maps to the default via
|
||||
:func:`_option_value` WITHOUT a warning (that is the normal
|
||||
missing-key path); only a present, non-``None``, invalid value
|
||||
triggers a warning and a per-field fallback.
|
||||
"""
|
||||
opts: Mapping[str, Any] = (
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
resolved: dict[str, Any] = {}
|
||||
for field, key, default, validator in _FIELDS:
|
||||
raw = _option_value(opts, key, default)
|
||||
try:
|
||||
resolved[field] = validator(raw)
|
||||
except ValueError as err:
|
||||
LOGGER.warning(
|
||||
"NestQuest option %s is invalid (%s); falling back "
|
||||
"to the default %r",
|
||||
field,
|
||||
err,
|
||||
default,
|
||||
)
|
||||
resolved[field] = default
|
||||
return cls(**resolved)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Options flow for NestQuest post-setup settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -33,18 +32,17 @@ from .const import (
|
||||
DEFAULT_UPDATE_INTERVAL,
|
||||
DOMAIN,
|
||||
MIN_UPDATE_INTERVAL,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
from .admin_allowlist import list_admin_ids, set_admin_ids
|
||||
|
||||
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
|
||||
return TIME_PATTERN.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _async_validate(user_input: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
@@ -24,8 +24,6 @@ from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
horizon_days = DEFAULT_HORIZON_DAYS
|
||||
else:
|
||||
horizon_days = value
|
||||
return {"today": today, "horizon_days": horizon_days}
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
|
||||
+38
-1
@@ -22,6 +22,7 @@ pytest -q``); it is never meant to run against a real HA install.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
@@ -916,4 +917,40 @@ def make_config_flow_entry():
|
||||
def _factory(domain: str, entry_id: str = "existing"):
|
||||
return SimpleNamespace(domain=domain, entry_id=entry_id)
|
||||
|
||||
return _factory
|
||||
return _factory
|
||||
|
||||
|
||||
def freeze_nestquest_clock(
|
||||
monkeypatch, instant: datetime.datetime
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched,
|
||||
so the generation path's ``datetime.datetime.now`` reads this instant
|
||||
(localized to whatever time zone it asks for) while every other module
|
||||
and the test harness keep the real clock. Returns ``(frozen, requested)``
|
||||
where ``frozen`` is the instant normalized to UTC and ``requested`` is
|
||||
the list of ``tzinfo`` objects the code under test passed to ``now`` —
|
||||
so a caller can prove the generation path actually requested the
|
||||
configured HA time zone rather than silently reading UTC.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
@@ -0,0 +1,912 @@
|
||||
"""Tests for core/settings.py: the explicit, HA-free settings object.
|
||||
|
||||
Covers the contracts from task 4dd8f870 plus the review fixes:
|
||||
|
||||
- ``NestQuestSettings.from_options`` applies defaults when keys are
|
||||
absent and rejects invalid horizon/rollover values; ``from_options``
|
||||
is strict (raises), while ``from_options_resilient`` falls back PER
|
||||
FIELD so a malformed sibling cannot reset horizon_days/rollover.
|
||||
- An AST scan asserts no module under
|
||||
``custom_components/nestquest/core/`` imports ``homeassistant``,
|
||||
names ``hass``/``config_entry``, reads a ``.config``/``.options``/
|
||||
``.data`` attribute, or dynamically reads them via ``getattr``/
|
||||
``hasattr``. (Docstring mentions are allowed.) A negative-control
|
||||
test feeds the scanner each bypass shape and asserts it flags them.
|
||||
- A settings object constructed with NON-default ``horizon_days`` and
|
||||
``day_rollover_time`` drives a materialization whose resulting window
|
||||
matches the non-default horizon — via BOTH the core ``materialize``
|
||||
path and the INTEGRATION path (``_run_horizon_materialization`` +
|
||||
``_register_day_rollover_listener``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import datetime
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import custom_components.nestquest.core as _core_mod
|
||||
from conftest import freeze_nestquest_clock
|
||||
|
||||
from custom_components.nestquest.const import (
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
DEFAULT_END_OF_DAY_REPORT_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
)
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_options: defaults + validation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_options_none_yields_all_defaults() -> None:
|
||||
"""A None mapping yields a settings object with every default applied."""
|
||||
s = NestQuestSettings.from_options(None)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert s.notify_target == ""
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
assert s.afternoon_reminder_time == DEFAULT_AFTERNOON_REMINDER_TIME
|
||||
assert s.end_of_day_report_time == DEFAULT_END_OF_DAY_REPORT_TIME
|
||||
assert s.morning_summary_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.afternoon_reminder_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.end_of_day_report_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
assert s.celebration_enabled is DEFAULT_AUTOMATION_ENABLED
|
||||
|
||||
|
||||
def test_from_options_empty_mapping_yields_all_defaults() -> None:
|
||||
"""An empty mapping also yields every default (the fresh-install case)."""
|
||||
s = NestQuestSettings.from_options({})
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
|
||||
|
||||
def test_from_options_reads_keys() -> None:
|
||||
"""Each CONF_* key is read into the matching field."""
|
||||
opts = {
|
||||
CONF_HORIZON_DAYS: 21,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_NOTIFY_TARGET: "notify.mobile_app_x",
|
||||
CONF_MORNING_SUMMARY_TIME: "07:15",
|
||||
CONF_AFTERNOON_REMINDER_TIME: "14:45",
|
||||
CONF_END_OF_DAY_REPORT_TIME: "21:30",
|
||||
CONF_MORNING_SUMMARY_ENABLED: False,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED: False,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED: False,
|
||||
CONF_CELEBRATION_ENABLED: False,
|
||||
}
|
||||
s = NestQuestSettings.from_options(opts)
|
||||
assert s.horizon_days == 21
|
||||
assert s.day_rollover_time == "03:30"
|
||||
assert s.notify_target == "notify.mobile_app_x"
|
||||
assert s.morning_summary_time == "07:15"
|
||||
assert s.afternoon_reminder_time == "14:45"
|
||||
assert s.end_of_day_report_time == "21:30"
|
||||
assert s.morning_summary_enabled is False
|
||||
assert s.afternoon_reminder_enabled is False
|
||||
assert s.end_of_day_report_enabled is False
|
||||
assert s.celebration_enabled is False
|
||||
|
||||
|
||||
def test_from_options_does_not_mutate_caller_mapping() -> None:
|
||||
"""from_options consumes the mapping read-only."""
|
||||
opts: dict[str, Any] = {CONF_HORIZON_DAYS: 9}
|
||||
snapshot = dict(opts)
|
||||
_ = NestQuestSettings.from_options(opts)
|
||||
assert opts == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[True, False, "5", 0, -3, 1.5],
|
||||
)
|
||||
def test_from_options_rejects_invalid_horizon(value: Any) -> None:
|
||||
"""A malformed horizon_days is rejected.
|
||||
|
||||
A key that is ABSENT (or present-but-None) falls back to the
|
||||
default — see :func:`test_from_options_horizon_absent_falls_back_to_default`
|
||||
and the None case below; any other invalid value raises, matching
|
||||
the options flow's strict validation.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="horizon_days"):
|
||||
NestQuestSettings.from_options({CONF_HORIZON_DAYS: value})
|
||||
|
||||
|
||||
def test_from_options_horizon_absent_falls_back_to_default() -> None:
|
||||
"""An absent (or None) horizon_days key falls back to the default."""
|
||||
assert NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_HORIZON_DAYS: None}
|
||||
).horizon_days
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_horizon_validates_and_defaults() -> None:
|
||||
"""from_options returns the configured horizon, defaulting when absent/None.
|
||||
|
||||
A valid horizon is read; an absent or None key falls back to the
|
||||
default; and a malformed value RAISES (the integration's
|
||||
``from_options_resilient`` handles the per-field fall-back at setup,
|
||||
not this strict path).
|
||||
"""
|
||||
assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5
|
||||
assert (
|
||||
NestQuestSettings.from_options({}).horizon_days == DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_HORIZON_DAYS: None}
|
||||
).horizon_days
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
for bad in (True, "5", 0):
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_HORIZON_DAYS: bad})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
["25:00", "00:60", "24:00", "9:5", "abc", "07:00:00", 700, ""],
|
||||
)
|
||||
def test_from_options_rejects_invalid_rollover(value: Any) -> None:
|
||||
"""A malformed day_rollover_time is rejected.
|
||||
|
||||
A key that is ABSENT (or present-but-None) falls back to the
|
||||
default — a present-but-empty-string or non-HH:MM value raises,
|
||||
matching the options flow's strict validation.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_DAY_ROLLOVER_TIME: value})
|
||||
|
||||
|
||||
def test_from_options_rollover_absent_falls_back_to_default() -> None:
|
||||
"""An absent (or None) day_rollover_time key falls back to the default."""
|
||||
assert (
|
||||
NestQuestSettings.from_options({}).day_rollover_time
|
||||
== DEFAULT_DAY_ROLLOVER_TIME
|
||||
)
|
||||
assert (
|
||||
NestQuestSettings.from_options(
|
||||
{CONF_DAY_ROLLOVER_TIME: None}
|
||||
).day_rollover_time
|
||||
== DEFAULT_DAY_ROLLOVER_TIME
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_AFTERNOON_REMINDER_TIME,
|
||||
CONF_END_OF_DAY_REPORT_TIME,
|
||||
],
|
||||
)
|
||||
def test_from_options_rejects_invalid_notification_time(key: str) -> None:
|
||||
"""The three notification times are validated as strict HH:MM."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({key: "25:99"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
CONF_MORNING_SUMMARY_ENABLED,
|
||||
CONF_AFTERNOON_REMINDER_ENABLED,
|
||||
CONF_END_OF_DAY_REPORT_ENABLED,
|
||||
CONF_CELEBRATION_ENABLED,
|
||||
],
|
||||
)
|
||||
def test_from_options_rejects_non_bool_toggle(key: str) -> None:
|
||||
"""Each toggle must be a real bool (no string coercion)."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({key: "yes"})
|
||||
|
||||
|
||||
def test_from_options_rejects_non_string_notify_target() -> None:
|
||||
"""notify_target must be a string (or absent)."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings.from_options({CONF_NOTIFY_TARGET: 123})
|
||||
|
||||
|
||||
def test_from_options_strips_notify_target() -> None:
|
||||
"""A padded notify target is stripped; empty stays empty."""
|
||||
s = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " notify.x "})
|
||||
assert s.notify_target == "notify.x"
|
||||
s_empty = NestQuestSettings.from_options({CONF_NOTIFY_TARGET: " "})
|
||||
assert s_empty.notify_target == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# from_options_resilient: per-field fallback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_options_resilient_none_yields_all_defaults() -> None:
|
||||
"""A None mapping yields the all-defaults settings (no warnings)."""
|
||||
s = NestQuestSettings.from_options_resilient(None)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
|
||||
|
||||
def test_from_options_resilient_reads_valid_keys() -> None:
|
||||
"""Valid keys are read exactly as from_options would read them."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_HORIZON_DAYS: 3, CONF_DAY_ROLLOVER_TIME: "03:30"}
|
||||
)
|
||||
assert s.horizon_days == 3
|
||||
assert s.day_rollover_time == "03:30"
|
||||
|
||||
|
||||
def test_from_options_resilient_keeps_horizon_when_sibling_is_bad(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed SIBLING field cannot reset horizon_days or day_rollover_time.
|
||||
|
||||
The old all-or-nothing fallback reset EVERY field when from_options
|
||||
raised for ANY field: ``{horizon_days: 3, morning_summary_time:
|
||||
'25:00'}`` yielded horizon 14 (was 3). The resilient builder falls
|
||||
back PER FIELD, so horizon_days=3 and day_rollover_time='03:30'
|
||||
survive a malformed morning_summary_time, and the bad field alone
|
||||
falls back to its default with a warning naming it.
|
||||
"""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{
|
||||
CONF_HORIZON_DAYS: 3,
|
||||
CONF_DAY_ROLLOVER_TIME: "03:30",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
)
|
||||
assert s.horizon_days == 3
|
||||
assert s.day_rollover_time == "03:30"
|
||||
# The malformed sibling fell back to its default.
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
# A warning was logged naming the bad field.
|
||||
assert any(
|
||||
"morning_summary_time" in record.getMessage()
|
||||
and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_bad_horizon_falls_back_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed horizon_days itself falls back to the default + warning."""
|
||||
s = NestQuestSettings.from_options_resilient({CONF_HORIZON_DAYS: True})
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert any(
|
||||
"horizon_days" in record.getMessage() and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_bad_rollover_falls_back_with_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A malformed day_rollover_time falls back to the default + warning."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_DAY_ROLLOVER_TIME: "99:99"}
|
||||
)
|
||||
assert s.day_rollover_time == DEFAULT_DAY_ROLLOVER_TIME
|
||||
assert any(
|
||||
"day_rollover_time" in record.getMessage()
|
||||
and record.levelname == "WARNING"
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_from_options_resilient_none_value_is_not_a_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A None value maps to the default silently (it is absent, not malformed)."""
|
||||
s = NestQuestSettings.from_options_resilient(
|
||||
{CONF_HORIZON_DAYS: None, CONF_MORNING_SUMMARY_TIME: None}
|
||||
)
|
||||
assert s.horizon_days == DEFAULT_HORIZON_DAYS
|
||||
assert s.morning_summary_time == DEFAULT_MORNING_SUMMARY_TIME
|
||||
assert not [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction + frozenness.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_direct_construction_validates() -> None:
|
||||
"""Hand-built instances validate the same way from_options does."""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(horizon_days=0)
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(day_rollover_time="bad")
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(morning_summary_enabled="yes")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,kwargs",
|
||||
[
|
||||
("horizon_days", {"horizon_days": None}),
|
||||
("day_rollover_time", {"day_rollover_time": None}),
|
||||
("notify_target", {"notify_target": None}),
|
||||
("morning_summary_time", {"morning_summary_time": None}),
|
||||
("afternoon_reminder_time", {"afternoon_reminder_time": None}),
|
||||
("end_of_day_report_time", {"end_of_day_report_time": None}),
|
||||
("morning_summary_enabled", {"morning_summary_enabled": None}),
|
||||
("afternoon_reminder_enabled", {"afternoon_reminder_enabled": None}),
|
||||
("end_of_day_report_enabled", {"end_of_day_report_enabled": None}),
|
||||
("celebration_enabled", {"celebration_enabled": None}),
|
||||
],
|
||||
)
|
||||
def test_direct_construction_rejects_none(field: str, kwargs: dict[str, Any]) -> None:
|
||||
"""__post_init__ is STRICT: None is rejected for every field.
|
||||
|
||||
The None->default mapping lives ONLY in from_options (via
|
||||
_option_value). A hand-built NestQuestSettings(field=None) must raise
|
||||
in __post_init__ rather than constructing and later blowing up
|
||||
horizon_window (timedelta(days=None) -> TypeError) or the rollover
|
||||
listener.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
NestQuestSettings(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, False, "5", 1.5])
|
||||
def test_direct_construction_rejects_non_int_horizon(value: Any) -> None:
|
||||
"""A bool or non-int horizon is rejected at construction (not bool-as-int)."""
|
||||
with pytest.raises(ValueError, match="horizon_days"):
|
||||
NestQuestSettings(horizon_days=value)
|
||||
|
||||
|
||||
|
||||
def test_direct_construction_strips_notify_target() -> None:
|
||||
"""__post_init__ normalises notify_target the SAME way from_options does.
|
||||
|
||||
P3: a hand-built ``NestQuestSettings(notify_target=' x ')`` is
|
||||
stripped to ``'x'`` (and a whitespace-only value to ``''``) so direct
|
||||
construction behaves like the options flow; a padded value cannot
|
||||
slip through __post_init__ either.
|
||||
"""
|
||||
assert NestQuestSettings(notify_target=" notify.x ").notify_target == "notify.x"
|
||||
assert NestQuestSettings(notify_target=" ").notify_target == ""
|
||||
assert NestQuestSettings(notify_target="notify.x").notify_target == "notify.x"
|
||||
|
||||
|
||||
def test_settings_is_frozen() -> None:
|
||||
"""The dataclass is frozen so a stale settings object cannot be mutated."""
|
||||
s = NestQuestSettings()
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
s.horizon_days = 99 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_day_rollover_hour_minute() -> None:
|
||||
"""day_rollover_hour_minute returns the parsed (hour, minute)."""
|
||||
s = NestQuestSettings(day_rollover_time="03:30")
|
||||
assert s.day_rollover_hour_minute == (3, 30)
|
||||
assert NestQuestSettings().day_rollover_hour_minute == (0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# horizon_window.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_horizon_window_pinned_today() -> None:
|
||||
"""A pinned today is honored so the window is deterministic.
|
||||
|
||||
``today`` is REQUIRED (no host-clock default) — see
|
||||
:meth:`NestQuestSettings.horizon_window`.
|
||||
"""
|
||||
s = NestQuestSettings(horizon_days=5)
|
||||
pinned = datetime.date(2026, 3, 1)
|
||||
start, end = s.horizon_window(pinned)
|
||||
assert start == pinned
|
||||
assert end == pinned + datetime.timedelta(days=5)
|
||||
|
||||
|
||||
def test_horizon_window_requires_today() -> None:
|
||||
"""horizon_window has no default today: omitting it raises TypeError."""
|
||||
s = NestQuestSettings(horizon_days=5)
|
||||
with pytest.raises(TypeError):
|
||||
s.horizon_window() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_horizon_window_non_default_horizon() -> None:
|
||||
"""A non-default horizon_days widens the window accordingly."""
|
||||
pinned = datetime.date(2026, 3, 1)
|
||||
short = NestQuestSettings(horizon_days=3).horizon_window(pinned)
|
||||
long_ = NestQuestSettings(horizon_days=30).horizon_window(pinned)
|
||||
assert short[1] == pinned + datetime.timedelta(days=3)
|
||||
assert long_[1] == pinned + datetime.timedelta(days=30)
|
||||
assert (long_[1] - short[1]).days == 27
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST: no HA config reads under core/.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CORE_PKG = Path(_core_mod.__file__).parent
|
||||
|
||||
|
||||
def _is_homeassistant(module: str) -> bool:
|
||||
return module == "homeassistant" or module.startswith("homeassistant.")
|
||||
|
||||
|
||||
#: HA-specific identifiers with no legitimate core use. ``entry`` is
|
||||
#: deliberately NOT in this set: core uses ``entry`` as a domain loop
|
||||
#: variable (iterating weekday_set entries in recurrence/presence/
|
||||
#: quest_definitions), so forbidding the bare name would false-positive.
|
||||
#: The config-entry attribute reads the bare-name check misses are
|
||||
#: caught by _CONFIG_ATTRS and _FORBIDDEN_ATTRS below.
|
||||
_FORBIDDEN_NAMES = frozenset({"hass", "config_entry"})
|
||||
|
||||
#: Attribute names that signal a HA object reference (self.hass,
|
||||
#: self.entry, obj.config_entry). No core module currently uses any of
|
||||
#: these as an attribute, so flagging them is false-positive-free.
|
||||
_FORBIDDEN_ATTRS = frozenset({"hass", "entry", "config_entry"})
|
||||
|
||||
#: Config-entry attribute reads — the shapes ``hass.config``,
|
||||
#: ``config_entry.options``, ``self.entry.options``, ``runtime.options``,
|
||||
#: ``entry.data``. Flagging the attribute ANYWHERE (regardless of root)
|
||||
#: catches ``runtime.options``, whose root name is not a forbidden
|
||||
#: identifier. No core module currently reads any of these attributes.
|
||||
_CONFIG_ATTRS = frozenset({"config", "options", "data"})
|
||||
|
||||
#: Dynamic config-entry reads via getattr/hasattr with a string argument.
|
||||
_GETATTR_FUNCS = frozenset({"getattr", "hasattr"})
|
||||
|
||||
#: The dynamic-import function the string-based import bypass uses.
|
||||
#: ``importlib.import_module("homeassistant...")`` reaches the HA package
|
||||
#: without an ``import homeassistant`` statement the static import check
|
||||
#: sees, so the scanner must recognise the call shape too.
|
||||
_IMPORT_MODULE_FUNCS = frozenset({"import_module"})
|
||||
|
||||
|
||||
def _is_import_module_call(node: ast.Call) -> bool:
|
||||
"""True for ``importlib.import_module(...)`` or a bare ``import_module(...)``."""
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
return func.attr in _IMPORT_MODULE_FUNCS
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id in _IMPORT_MODULE_FUNCS
|
||||
return False
|
||||
|
||||
|
||||
def _scan_source_for_ha_config(
|
||||
source: str, *, name: str = "<snippet>"
|
||||
) -> list[str]:
|
||||
"""Return offender strings for HA-coupling in ``source``.
|
||||
|
||||
Flags (each closes a bypass a root-only ``hass``/``entry`` chain
|
||||
check would not catch):
|
||||
|
||||
- any ``import homeassistant`` / ``from homeassistant ...``;
|
||||
- any ``importlib.import_module('homeassistant...')`` (a string-based
|
||||
import that reaches HA without an ``import homeassistant`` statement
|
||||
the static import check can see);
|
||||
- any ``Name``/``arg`` whose id is ``hass`` or ``config_entry``
|
||||
(HA-specific identifiers with no legitimate core use);
|
||||
- any call keyword whose name is ``hass`` or ``config_entry``
|
||||
(catches ``foo(hass=...)`` / ``handler(config_entry=...)`` — the
|
||||
bare-Name check misses keyword arguments);
|
||||
- any attribute access whose ``attr`` is ``hass``/``entry``/
|
||||
``config_entry`` (catches ``self.hass``, ``self.entry``,
|
||||
``obj.config_entry`` — root names a root-only check could not see);
|
||||
- any attribute access whose ``attr`` is ``config``/``options``/
|
||||
``data`` — these are Home Assistant config-entry attributes with no
|
||||
legitimate core use (``.config`` reads ``hass.config``, ``.options``
|
||||
reads ``config_entry.options``, ``.data`` reads ``config_entry.data``);
|
||||
flagging the attribute ANYWHERE (regardless of root) is what catches
|
||||
``runtime.options``, whose root name is not a forbidden identifier.
|
||||
No core module currently reads any of these attributes;
|
||||
- any ``getattr``/``hasattr`` call whose string argument is
|
||||
``'config'``/``'options'``/``'data'`` (catches the dynamic read
|
||||
``getattr(hass, 'config')`` the static attribute check cannot see).
|
||||
|
||||
Docstring mentions are structurally exempt: AST only sees code, and
|
||||
none of these node shapes appear inside a string literal. The
|
||||
existing docstring references to ``hass.config`` in ``core/events.py``
|
||||
and ``core/store.py`` therefore pass.
|
||||
"""
|
||||
tree = ast.parse(source, filename=name)
|
||||
offenders: list[str] = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if _is_homeassistant(alias.name):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: "
|
||||
f"import {alias.name}"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
base = node.module or ""
|
||||
if _is_homeassistant(base):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: from {base}"
|
||||
)
|
||||
for alias in node.names:
|
||||
combined = f"{base}.{alias.name}" if base else alias.name
|
||||
if _is_homeassistant(combined):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} homeassistant import: "
|
||||
f"from {base} import {alias.name}"
|
||||
)
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden identifier: {node.id!r}"
|
||||
)
|
||||
elif isinstance(node, ast.arg):
|
||||
if node.arg in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden parameter: {node.arg!r}"
|
||||
)
|
||||
elif isinstance(node, ast.keyword):
|
||||
# A call keyword like ``foo(hass=...)``: the bare-Name check
|
||||
# sees the value, not the keyword's own name, so a HA object
|
||||
# could be passed by keyword without tripping it. ``arg`` is
|
||||
# ``None`` for ``**kwargs`` unpacking, which is not a name.
|
||||
if node.arg is not None and node.arg in _FORBIDDEN_NAMES:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden keyword argument: "
|
||||
f"{node.arg!r}"
|
||||
)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
if node.attr in _FORBIDDEN_ATTRS:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} forbidden attribute: .{node.attr}"
|
||||
)
|
||||
if node.attr in _CONFIG_ATTRS:
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} config-entry attribute read: "
|
||||
f".{node.attr}"
|
||||
)
|
||||
elif isinstance(node, ast.Call):
|
||||
if _is_import_module_call(node):
|
||||
# The first positional argument is the module string; a
|
||||
# constant ``'homeassistant...'`` is the bypass shape.
|
||||
for arg in node.args:
|
||||
if (
|
||||
isinstance(arg, ast.Constant)
|
||||
and isinstance(arg.value, str)
|
||||
and _is_homeassistant(arg.value)
|
||||
):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} dynamic homeassistant "
|
||||
f"import: import_module({arg.value!r})"
|
||||
)
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS:
|
||||
for arg in node.args:
|
||||
if (
|
||||
isinstance(arg, ast.Constant)
|
||||
and isinstance(arg.value, str)
|
||||
and arg.value in _CONFIG_ATTRS
|
||||
):
|
||||
offenders.append(
|
||||
f"{name}:{node.lineno} {func.id}() dynamic "
|
||||
f"config-entry read: {arg.value!r}"
|
||||
)
|
||||
|
||||
return offenders
|
||||
|
||||
|
||||
def _scan_module_for_ha_config(path: Path) -> list[str]:
|
||||
"""Scan a core module file for HA-coupling (file-backed scan)."""
|
||||
return _scan_source_for_ha_config(
|
||||
path.read_text(encoding="utf-8"), name=path.name
|
||||
)
|
||||
|
||||
|
||||
def test_core_modules_have_no_ha_config_reads() -> None:
|
||||
"""No core module imports homeassistant or reads HA config / entry options.
|
||||
|
||||
The scan forbids the HA-specific identifiers ``hass``/``config_entry``,
|
||||
the HA-object attributes ``.hass``/``.entry``/``.config_entry``, the
|
||||
config-entry attribute reads ``.config``/``.options``/``.data``, and
|
||||
dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at
|
||||
``self`` or ``runtime`` (whose root name is not a forbidden
|
||||
identifier) is caught. ``.config``/``.options``/``.data`` are banned
|
||||
because they are Home Assistant config-entry attributes with no
|
||||
legitimate core use: core receives the resolved values via the
|
||||
explicit :class:`NestQuestSettings` object the integration builds
|
||||
from ``entry.options``, so any read of those attributes in ``core/``
|
||||
is a config-entry coupling the settings object exists to remove.
|
||||
Docstring mentions pass: AST only sees code.
|
||||
"""
|
||||
py_files = sorted(CORE_PKG.rglob("*.py"))
|
||||
assert py_files, "core package not found"
|
||||
all_offenders: list[str] = []
|
||||
for py_file in py_files:
|
||||
all_offenders.extend(_scan_module_for_ha_config(py_file))
|
||||
assert all_offenders == [], (
|
||||
"core package reads HA config / entry options:\n"
|
||||
+ "\n".join(all_offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_ast_scan_flags_each_bypass_shape() -> None:
|
||||
"""Negative control: the scanner flags each bypass a root-only check misses.
|
||||
|
||||
A root-only scan flags chains rooted at a literal name ``hass``/
|
||||
``entry``. Each snippet below is a shape such a scan would miss;
|
||||
this scanner must flag every one.
|
||||
"""
|
||||
snippets: dict[str, str] = {
|
||||
"self.hass.config": "x = self.hass.config\n",
|
||||
"config_entry.options": "x = config_entry.options\n",
|
||||
"self.entry.options": "x = self.entry.options\n",
|
||||
"getattr(hass,'config')": "x = getattr(hass, 'config')\n",
|
||||
"runtime.options": "x = runtime.options\n",
|
||||
"hasattr(obj,'data')": "x = hasattr(obj, 'data')\n",
|
||||
"importlib.import_module('homeassistant')": (
|
||||
"import importlib\nimportlib.import_module('homeassistant')\n"
|
||||
),
|
||||
"import_module('homeassistant.helpers')": (
|
||||
"from importlib import import_module\n"
|
||||
"import_module('homeassistant.helpers')\n"
|
||||
),
|
||||
"handler(hass=...)": "handler(hass=obj)\n",
|
||||
"handler(config_entry=...)": "handler(config_entry=obj)\n",
|
||||
}
|
||||
for label, src in snippets.items():
|
||||
offenders = _scan_source_for_ha_config(src, name=label)
|
||||
assert offenders, f"scanner missed {label!r}: {src!r}"
|
||||
|
||||
|
||||
def test_ast_scan_allows_non_homeassistant_import_module() -> None:
|
||||
"""A dynamic import of a NON-homeassistant module is not flagged.
|
||||
|
||||
``importlib.import_module`` is a legitimate way to load a non-HA
|
||||
module lazily; only a ``homeassistant...`` target is the bypass shape.
|
||||
"""
|
||||
src = (
|
||||
"import importlib\n"
|
||||
"m = importlib.import_module('custom_components.nestquest.core')\n"
|
||||
)
|
||||
assert _scan_source_for_ha_config(src, name="non-ha-import") == []
|
||||
|
||||
|
||||
def test_ast_scan_allows_non_forbidden_keyword_arguments() -> None:
|
||||
"""A call with non-forbidden keyword arguments is not flagged.
|
||||
|
||||
Only ``hass=``/``config_entry=`` are forbidden as keyword names; an
|
||||
unrelated keyword like ``hass_data=`` or ``entry_id=`` must not
|
||||
false-positive (and ``**kwargs`` unpacking has ``arg=None``).
|
||||
"""
|
||||
src = (
|
||||
"foo(entry_id=1, name='x')\n"
|
||||
"bar(**kwargs)\n"
|
||||
)
|
||||
assert _scan_source_for_ha_config(src, name="non-forbidden-kwargs") == []
|
||||
|
||||
|
||||
def test_ast_scan_allows_legitimate_entry_loop_var() -> None:
|
||||
"""A bare ``entry`` loop variable (a domain concept) is NOT flagged.
|
||||
|
||||
``entry`` is used in core as a loop variable over weekday_set entries
|
||||
(recurrence.py, presence.py, quest_definitions.py) — not a HA config
|
||||
entry. Forbidding the bare name would false-positive on those, so
|
||||
only ``hass``/``config_entry`` are forbidden as bare names; the
|
||||
config-entry reads via ``entry.options``/``entry.data`` are caught by
|
||||
the ``_CONFIG_ATTRS`` attribute check instead.
|
||||
"""
|
||||
offenders = _scan_source_for_ha_config(
|
||||
"for entry in items:\n pass\n", name="loop"
|
||||
)
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
def test_ast_scan_allows_clean_core_shape() -> None:
|
||||
"""A clean snippet with no HA coupling is not flagged (no false positives)."""
|
||||
src = "opts = {'a': 1}\nx = opts.get('a')\nfor entry in items:\n pass\n"
|
||||
offenders = _scan_source_for_ha_config(src, name="clean")
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
def test_settings_module_lives_under_core() -> None:
|
||||
"""settings.py is part of the core package (not the integration root)."""
|
||||
assert (CORE_PKG / "settings.py").is_file()
|
||||
# And it is importable as a core submodule.
|
||||
import custom_components.nestquest.core.settings as mod
|
||||
|
||||
assert hasattr(mod, "NestQuestSettings")
|
||||
assert isinstance(mod.NestQuestSettings, type)
|
||||
|
||||
|
||||
def test_settings_module_has_no_ha_imports() -> None:
|
||||
"""settings.py specifically carries no homeassistant import or config read."""
|
||||
offenders = _scan_module_for_ha_config(CORE_PKG / "settings.py")
|
||||
assert offenders == [], offenders
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialization over a non-default horizon: core path + integration path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _prepare(path, executor):
|
||||
"""Open and migrate a database backed by ``executor`` (executor-only DB access)."""
|
||||
from custom_components.nestquest.core.db import NestQuestDatabase
|
||||
from custom_components.nestquest.core.migrations import apply_migrations
|
||||
|
||||
database = NestQuestDatabase(executor)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
|
||||
|
||||
async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None:
|
||||
"""A settings object with a non-default horizon drives the matching window.
|
||||
|
||||
Constructs NestQuestSettings with horizon_days=3 and day_rollover_time
|
||||
'03:30' (both non-default), builds the window via horizon_window, and
|
||||
drives the CORE ``materialize`` over it; asserts the resulting instance
|
||||
rows land exactly on the [today, today+3] window and NOT beyond it.
|
||||
"""
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.materialize import materialize
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(
|
||||
horizon_days=3, day_rollover_time="03:30"
|
||||
)
|
||||
assert settings.horizon_days == 3
|
||||
assert settings.day_rollover_time == "03:30"
|
||||
|
||||
today = datetime.date(2026, 3, 1)
|
||||
start, end = settings.horizon_window(today)
|
||||
assert start == today
|
||||
assert end == today + datetime.timedelta(days=3)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
count = await materialize(
|
||||
database,
|
||||
start.isoformat(),
|
||||
end.isoformat(),
|
||||
today=today,
|
||||
)
|
||||
# 4 days (today..today+3 inclusive), one instance per day.
|
||||
assert count == 4
|
||||
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, start.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
|
||||
# Beyond the configured horizon: no instances.
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
|
||||
async def test_integration_path_threads_settings(
|
||||
hass, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""The integration path threads ``settings`` through horizon_window + rollover.
|
||||
|
||||
``_run_horizon_materialization`` calls
|
||||
``settings.horizon_window(today)`` (so the helper is real and shared,
|
||||
not recomputed inline), and ``_register_day_rollover_listener``
|
||||
registers at ``settings.day_rollover_hour_minute``. This runs the
|
||||
INTEGRATION path with ``horizon_days=3`` and
|
||||
``day_rollover_time='03:30'`` and asserts the resulting instance
|
||||
window matches [today, today+3] AND the registered hour/minute are 3/30.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
from custom_components.nestquest.core.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.core.dao_instances import (
|
||||
QuestInstancesDao,
|
||||
)
|
||||
from custom_components.nestquest.core.quest_definitions import (
|
||||
create_quest_definition,
|
||||
)
|
||||
from custom_components.nestquest.core.recurrence import (
|
||||
RuleType,
|
||||
ScheduleRule,
|
||||
)
|
||||
|
||||
# Freeze the integration clock so "today" is deterministic (the
|
||||
# hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC
|
||||
# localizes to 2026-03-01).
|
||||
freeze_nestquest_clock(
|
||||
monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc)
|
||||
)
|
||||
|
||||
database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job)
|
||||
try:
|
||||
settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30")
|
||||
today = datetime.date(2026, 3, 1)
|
||||
|
||||
NOW = "2026-03-01T12:00:00+00:00"
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date=today.isoformat()
|
||||
),
|
||||
[child.id],
|
||||
["morning"],
|
||||
)
|
||||
|
||||
# Integration path: the window comes from settings.horizon_window.
|
||||
await nestquest._run_horizon_materialization(hass, database, settings)
|
||||
|
||||
end = today + datetime.timedelta(days=3)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, today.isoformat(), end.isoformat()
|
||||
)
|
||||
dates = sorted({r.due_date for r in records})
|
||||
assert dates == [
|
||||
(today + datetime.timedelta(days=i)).isoformat()
|
||||
for i in range(4)
|
||||
]
|
||||
beyond = (end + datetime.timedelta(days=1)).isoformat()
|
||||
far = (end + datetime.timedelta(days=10)).isoformat()
|
||||
assert await QuestInstancesDao(database).list_by_date_range(
|
||||
child.id, beyond, far
|
||||
) == []
|
||||
|
||||
# The rollover listener registers at the configured hour/minute.
|
||||
remove = nestquest._register_day_rollover_listener(
|
||||
hass, database, settings
|
||||
)
|
||||
try:
|
||||
reg = hass.time_change.registrations[-1]
|
||||
assert reg["hour"] == 3
|
||||
assert reg["minute"] == 30
|
||||
finally:
|
||||
remove()
|
||||
finally:
|
||||
await database.close()
|
||||
+72
-17
@@ -12,6 +12,7 @@ from custom_components.nestquest.const import (
|
||||
CONF_ADMIN_USER_IDS,
|
||||
CONF_DAY_ROLLOVER_TIME,
|
||||
CONF_HORIZON_DAYS,
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
SERVICE_REGENERATE,
|
||||
DOMAIN as DOMAIN_CONST,
|
||||
@@ -36,12 +37,12 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
|
||||
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:
|
||||
async def test_setup_runtime_data_has_entry_id_and_settings(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 runtime.settings.horizon_days == 7
|
||||
assert callable(runtime.remove_update_listener)
|
||||
|
||||
|
||||
@@ -526,24 +527,78 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
|
||||
).list_by_date_range(child_id, beyond, beyond_end) == []
|
||||
|
||||
|
||||
def test_configured_horizon_days_validates_and_falls_back() -> None:
|
||||
"""The configured horizon is validated; a malformed value uses the default."""
|
||||
import custom_components.nestquest as nestquest
|
||||
async def test_setup_succeeds_with_fully_malformed_options(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A fully malformed options dict falls back per field and setup succeeds.
|
||||
|
||||
assert nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 5}) == 5
|
||||
assert nestquest._configured_horizon_days({}) == DEFAULT_HORIZON_DAYS
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: True})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
``horizon_days=True``, ``day_rollover_time='99:99'`` and
|
||||
``morning_summary_time='25:00'`` are all invalid; the integration's
|
||||
``from_options_resilient`` falls each back to its default, so setup
|
||||
SUCCEEDS, the day-rollover listener registers at the default hour 0
|
||||
minute 0, and the startup backfill materializes the default horizon
|
||||
window.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
from custom_components.nestquest.migrations import apply_migrations
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
await pre.close()
|
||||
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: True,
|
||||
CONF_DAY_ROLLOVER_TIME: "99:99",
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: "5"})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
)
|
||||
assert (
|
||||
nestquest._configured_horizon_days({CONF_HORIZON_DAYS: 0})
|
||||
== DEFAULT_HORIZON_DAYS
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
|
||||
# The day-rollover listener fell back to the default 00:00.
|
||||
registration = hass.time_change.registrations[0]
|
||||
assert registration["hour"] == 0
|
||||
assert registration["minute"] == 0
|
||||
|
||||
# The startup backfill used the default horizon window.
|
||||
today = _local_today(hass)
|
||||
end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||
records = await QuestInstancesDao(
|
||||
entry.runtime_data.database
|
||||
).list_by_date_range(child_id, today.isoformat(), end)
|
||||
assert len(records) == DEFAULT_HORIZON_DAYS + 1
|
||||
|
||||
|
||||
async def test_setup_keeps_horizon_when_sibling_option_is_bad(
|
||||
hass, make_entry
|
||||
) -> None:
|
||||
"""A malformed sibling option does not reset the configured horizon.
|
||||
|
||||
``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is
|
||||
invalid; ``from_options_resilient`` falls back ONLY the bad sibling,
|
||||
so the entry's resolved settings keep ``horizon_days == 5``.
|
||||
"""
|
||||
entry = _wire(
|
||||
make_entry(
|
||||
options={
|
||||
CONF_HORIZON_DAYS: 5,
|
||||
CONF_MORNING_SUMMARY_TIME: "25:00",
|
||||
}
|
||||
),
|
||||
hass.registry,
|
||||
)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
assert entry.runtime_data.settings.horizon_days == 5
|
||||
|
||||
|
||||
async def test_regenerate_service_registered_and_materializes(
|
||||
|
||||
+17
-32
@@ -13,7 +13,9 @@ import datetime
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from conftest import freeze_nestquest_clock
|
||||
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
|
||||
from custom_components.nestquest.core.settings import NestQuestSettings
|
||||
from custom_components.nestquest.dao_children import ChildrenDao
|
||||
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
@@ -53,35 +55,10 @@ def _freeze_clock(
|
||||
) -> tuple[datetime.datetime, list]:
|
||||
"""Freeze the integration's ``datetime`` clock to a fixed aware instant.
|
||||
|
||||
Only ``custom_components.nestquest``'s ``datetime`` binding is patched, so
|
||||
the generation path's "today" reads this instant (localized to whatever
|
||||
time zone it asks for) while every other module and the test harness keep
|
||||
the real clock. Returns ``(frozen, requested)`` where ``frozen`` is the
|
||||
instant normalized to UTC and ``requested`` is the list of ``tzinfo``
|
||||
objects the code under test passed to ``now`` — so a test can prove the
|
||||
generation path actually requested the configured HA time zone rather
|
||||
than silently reading UTC.
|
||||
Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the
|
||||
``(frozen, requested)`` return shape the timezone assertions read.
|
||||
"""
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
frozen = instant.astimezone(datetime.timezone.utc)
|
||||
requested: list = []
|
||||
|
||||
class _FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
requested.append(tz)
|
||||
if tz is None:
|
||||
return frozen.replace(tzinfo=None)
|
||||
return frozen.astimezone(tz)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
datetime=_FrozenDatetime,
|
||||
timedelta=datetime.timedelta,
|
||||
date=datetime.date,
|
||||
)
|
||||
monkeypatch.setattr(nestquest, "datetime", fake_module)
|
||||
return frozen, requested
|
||||
return freeze_nestquest_clock(monkeypatch, instant)
|
||||
|
||||
|
||||
def _assert_requested_ha_timezone(requested, time_zone: str) -> None:
|
||||
@@ -147,7 +124,9 @@ def test_horizon_uses_newyork_local_date_after_local_midnight(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -187,7 +166,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -231,7 +212,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -280,7 +263,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
child_id = await _seed_daily(database, local_today.isoformat())
|
||||
await nestquest._run_horizon_materialization(hass, database)
|
||||
await nestquest._run_horizon_materialization(
|
||||
hass, database, NestQuestSettings()
|
||||
)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
|
||||
Reference in New Issue
Block a user