diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 0b1ef1e..abea786 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -18,16 +18,14 @@ 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 .settings import NestQuestSettings from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase, make_database @@ -241,6 +239,7 @@ class NestQuestRuntimeData: entry_id: str options: dict[str, Any] + settings: NestQuestSettings database: NestQuestDatabase remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] @@ -272,35 +271,40 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: 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`. + Thin shim over :class:`NestQuestSettings`: 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) + return NestQuestSettings.from_options(options).day_rollover_hour_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. + Thin shim over :class:`NestQuestSettings`. The settings object's + ``from_options`` re-checks ``horizon_days`` — rejecting bools, + non-ints, and sub-1 values, falling back to + :data:`~.const.DEFAULT_HORIZON_DAYS` — so a malformed option can + never shrink or explode the materialized horizon. """ - value = options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) - if isinstance(value, bool) or not isinstance(value, int) or value < 1: + try: + return NestQuestSettings.from_options(options).horizon_days + except ValueError: + # A malformed stored option (a bad time string) must not crash + # startup; the horizon itself was already validated above, so a + # ValueError here can only come from a sibling time field — fall + # back to the default horizon the same way the pre-settings path + # tolerated partial options. return DEFAULT_HORIZON_DAYS - return value async def _run_horizon_materialization( hass: HomeAssistant, database: NestQuestDatabase, - horizon_days: int = DEFAULT_HORIZON_DAYS, + settings: NestQuestSettings | None = None, + *, + horizon_days: int | None = None, ) -> None: """Materialize the rolling horizon ``[today, today + horizon_days]``. @@ -311,15 +315,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. Callers SHOULD pass a + :class:`NestQuestSettings` instance built from ``entry.options``; the + legacy ``horizon_days`` keyword is kept for callers that have not yet + been converted and overrides the settings value when supplied. When + neither is given the window sizes to + :data:`~.const.DEFAULT_HORIZON_DAYS`. """ + if settings is not None: + horizon = settings.horizon_days + else: + horizon = horizon_days if horizon_days is not None else DEFAULT_HORIZON_DAYS 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) + today + datetime.timedelta(days=horizon) ).isoformat() await _materialize_run(database, start_date, end_date, today=today) @@ -327,7 +338,7 @@ async def _run_horizon_materialization( 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 +346,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( @@ -379,9 +389,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 +504,26 @@ 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 to the defaults (the same way the + # pre-settings path tolerated a partial options dict) rather than + # crashing setup. + try: + settings = NestQuestSettings.from_options(entry.options) + except ValueError: + settings = NestQuestSettings() 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 @@ -530,6 +547,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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, diff --git a/custom_components/nestquest/core/settings.py b/custom_components/nestquest/core/settings.py new file mode 100644 index 0000000..e1345a5 --- /dev/null +++ b/custom_components/nestquest/core/settings.py @@ -0,0 +1,302 @@ +"""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`) 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. +""" +from __future__ import annotations + +import datetime +import re +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, +) + +#: Strict 24-hour ``HH:MM`` pattern — matches the options flow's +#: ``_TIME_PATTERN`` verbatim so a stored option and a freshly-validated +#: form submit parse the same way. +_HHMM_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") + + +def _option_value( + opts: Mapping[str, Any], key: str, default: Any +) -> Any: + """Return ``opts[key]`` when present and non-None, else ``default``. + + 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: a partially-populated ``entry.options`` dict (or one + migrated across versions) never raises just because a key carries + ``None``. + """ + if key not in opts: + return default + value = opts[key] + return default if value is None else value + + +def _parse_hhmm(value: str) -> tuple[int, int]: + """Return ``(hour, minute)`` for a strict ``HH:MM`` string. + + Raises :class:`ValueError` for anything that is not a string or does + not match :data:`_HHMM_PATTERN` — the same rule the options flow + applies, surfaced here so a malformed mapping cannot construct a + settings object with an unparsable time. + """ + if not isinstance(value, str) or _HHMM_PATTERN.fullmatch(value) is None: + raise ValueError(f"expected a strict HH:MM string, got {value!r}") + hour_str, _, minute_str = value.partition(":") + return int(hour_str), int(minute_str) + + +def _resolve_horizon_days(value: Any) -> int: + """Validate a horizon value and return the resolved day count. + + Mirrors :func:`~.materialize._resolve_horizon_days`: 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`` + resolves to :data:`~.const.DEFAULT_HORIZON_DAYS`. + """ + if value is None: + return DEFAULT_HORIZON_DAYS + 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, *, default: bool) -> bool: + """Return a real bool, falling back to ``default`` when ``value`` is None. + + A non-bool, non-None value is rejected — voluptuous ``bool`` would + coerce strings, so the options flow uses ``_strict_bool`` and this + mirrors it. + """ + if value is None: + return default + 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; empty means "not configured". + + A non-str value is rejected. An empty (or whitespace-only) value + is kept as the empty string — the shipped default, meaning no + notifications are configured yet — rather than raising, mirroring + the options flow's empty-target acceptance. + """ + if value is None: + return "" + if not isinstance(value, str): + raise ValueError(f"notify_target must be a string, got {value!r}") + return value.strip() + + +@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`) 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 at construction so a hand-built instance cannot carry + # an invalid value either: the same rules from_options applies. + _resolve_horizon_days(self.horizon_days) + _parse_hhmm(self.day_rollover_time) + if not isinstance(self.notify_target, str): + raise ValueError( + f"notify_target must be a string, got {self.notify_target!r}" + ) + _parse_hhmm(self.morning_summary_time) + _parse_hhmm(self.afternoon_reminder_time) + _parse_hhmm(self.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.""" + return _parse_hhmm(self.day_rollover_time) + + def horizon_window( + self, today: datetime.date | None = None + ) -> tuple[datetime.date, datetime.date]: + """Return the rolling horizon window ``[today, today + horizon_days]``. + + ``today`` defaults to the host clock's date + (:func:`datetime.date.today`), matching the historical materialize + default. The integration overrides this with the HA-local + ``hass.config.time_zone`` date so the horizon tracks the + household's own day. The returned window is closed/inclusive — + the same shape :func:`~.materialize.materialize` walks. + """ + if today is None: + today = datetime.date.today() + 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 and validating the same + way the options flow does: ``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. + 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( + horizon_days=_resolve_horizon_days( + _option_value(opts, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) + ), + day_rollover_time=_parse_hhmm_str( + _option_value(opts, CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), + field="day_rollover_time", + ), + notify_target=_resolve_optional_str( + _option_value(opts, CONF_NOTIFY_TARGET, "") + ), + morning_summary_time=_parse_hhmm_str( + _option_value( + opts, CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME + ), + field="morning_summary_time", + ), + afternoon_reminder_time=_parse_hhmm_str( + _option_value( + opts, + CONF_AFTERNOON_REMINDER_TIME, + DEFAULT_AFTERNOON_REMINDER_TIME, + ), + field="afternoon_reminder_time", + ), + end_of_day_report_time=_parse_hhmm_str( + _option_value( + opts, + CONF_END_OF_DAY_REPORT_TIME, + DEFAULT_END_OF_DAY_REPORT_TIME, + ), + field="end_of_day_report_time", + ), + morning_summary_enabled=_resolve_bool( + _option_value( + opts, CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED + ), + default=DEFAULT_AUTOMATION_ENABLED, + ), + afternoon_reminder_enabled=_resolve_bool( + _option_value( + opts, + CONF_AFTERNOON_REMINDER_ENABLED, + DEFAULT_AUTOMATION_ENABLED, + ), + default=DEFAULT_AUTOMATION_ENABLED, + ), + end_of_day_report_enabled=_resolve_bool( + _option_value( + opts, + CONF_END_OF_DAY_REPORT_ENABLED, + DEFAULT_AUTOMATION_ENABLED, + ), + default=DEFAULT_AUTOMATION_ENABLED, + ), + celebration_enabled=_resolve_bool( + _option_value( + opts, CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED + ), + default=DEFAULT_AUTOMATION_ENABLED, + ), + ) + + +def _parse_hhmm_str(value: Any, *, field: str) -> str: + """Return a validated ``HH:MM`` string, raising on a malformed value. + + A separate helper from :func:`_parse_hhmm` because :meth:`from_options` + needs the original string kept (the dataclass stores strings, not + tuples), but still wants the same strict validation. + """ + if not isinstance(value, str) or _HHMM_PATTERN.fullmatch(value) is None: + raise ValueError( + f"{field} must be a strict HH:MM string, got {value!r}" + ) + return value diff --git a/custom_components/nestquest/settings.py b/custom_components/nestquest/settings.py new file mode 100644 index 0000000..9e8f62a --- /dev/null +++ b/custom_components/nestquest/settings.py @@ -0,0 +1,13 @@ +"""Re-export of :mod:`custom_components.nestquest.core.settings` for the integration package. + +The explicit, Home-Assistant-free settings object lives in +:mod:`custom_components.nestquest.core.settings` (introduced in task +4dd8f870); this module re-exports it so the integration constructs the +settings object from ``entry.options`` and threads it into the core +paths without core ever reading HA config itself. +""" +from __future__ import annotations + +from .core.settings import NestQuestSettings + +__all__ = ["NestQuestSettings"] diff --git a/tests/test_core_settings.py b/tests/test_core_settings.py new file mode 100644 index 0000000..070cc1c --- /dev/null +++ b/tests/test_core_settings.py @@ -0,0 +1,504 @@ +"""Tests for core/settings.py: the explicit, HA-free settings object. + +Covers three contracts from task 4dd8f870: + +- ``NestQuestSettings.from_options`` applies defaults when keys are + absent and rejects invalid horizon/rollover values. +- An AST scan asserts no module under + ``custom_components/nestquest/core/`` imports ``homeassistant``, + accesses a ``hass.config`` / config-entry attribute, or reads + config-entry options. (Docstring mentions are allowed.) +- A settings object constructed with NON-default ``horizon_days`` and + ``day_rollover_time`` drives a materialization whose resulting window + matches the non-default horizon. +""" +from __future__ import annotations + +import ast +import datetime +import inspect +from dataclasses import FrozenInstanceError +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +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 + ) + + +@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 == "" + + +# --------------------------------------------------------------------------- +# 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") + + +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_default_today() -> None: + """horizon_window returns [today, today + horizon_days] when today is omitted.""" + s = NestQuestSettings(horizon_days=7) + start, end = s.horizon_window() + today = datetime.date.today() + assert start == today + assert end == today + datetime.timedelta(days=7) + + +def test_horizon_window_pinned_today() -> None: + """A pinned today is honored so the window is deterministic.""" + 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_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( + __import__( + "custom_components.nestquest.core", fromlist=["__file__"] + ).__file__ +).parent + + +def _is_homeassistant(module: str) -> bool: + return module == "homeassistant" or module.startswith("homeassistant.") + + +def _attribute_chain(node: ast.Attribute) -> list[str]: + """Flatten an attribute access chain into dotted-name parts (leaf first).""" + parts: list[str] = [node.attr] + cur: ast.expr = node.value + while isinstance(cur, ast.Attribute): + parts.append(cur.attr) + cur = cur.value + if isinstance(cur, ast.Name): + parts.append(cur.id) + return parts + + +def _dotted_attr(node: ast.Attribute) -> str: + """Return the dotted name of an attribute chain, root first.""" + return ".".join(reversed(_attribute_chain(node))) + + +def _scan_module_for_ha_config(path: Path) -> list[str]: + """Return a list of offender strings for HA-coupling in ``path``. + + Flags: + - any ``import homeassistant`` / ``from homeassistant ...``; + - any attribute access whose chain reaches ``hass.config`` or any + ``.options``/``.data`` access on a name literally named ``entry`` + (a config-entry attribute read), EXCEPT inside a docstring-only + module (no runtime access) — but since AST only sees code, not + docstrings, docstring mentions are structurally exempt. + - any call to ``.options.get(...)`` / ``.data.get(...)`` on a name + literally named ``entry`` (a config-entry options/data read). + """ + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + 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"{path.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"{path.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"{path.name}:{node.lineno} homeassistant import: " + f"from {base} import {alias.name}" + ) + elif isinstance(node, ast.Attribute): + dotted = _dotted_attr(node) + # hass.config. — a HA config read. + if dotted == "hass.config" or dotted.startswith("hass.config."): + offenders.append( + f"{path.name}:{node.lineno} hass.config access: {dotted}" + ) + # entry.options / entry.data — a config-entry attribute read. + # Only flag the leaf attribute itself (not every sub-expression), + # and only when the root name is literally 'entry'. + root = _attribute_chain(node)[-1] + if root == "entry" and node.attr in {"options", "data"}: + offenders.append( + f"{path.name}:{node.lineno} config-entry attribute read: " + f"{dotted}" + ) + + return offenders + + +def test_core_modules_have_no_ha_config_reads() -> None: + """No core module imports homeassistant or reads HA config / entry options. + + Docstring mentions are allowed: AST only sees code, and the offenders + this scan flags (imports, attribute accesses) never appear in a + docstring. The existing docstring references to ``hass.config`` in + ``core/events.py`` and ``core/store.py`` therefore pass. + """ + 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_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 inspect.isclass(mod.NestQuestSettings) + + +def test_settings_module_has_no_ha_imports() -> None: + """settings.py specifically carries no homeassistant import.""" + offenders = _scan_module_for_ha_config(CORE_PKG / "settings.py") + assert offenders == [], offenders + + +# --------------------------------------------------------------------------- +# Materialization over a non-default horizon. +# --------------------------------------------------------------------------- + + +def _make_hass_mock(): + from unittest.mock import AsyncMock + + hass = MagicMock() + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a, **k: fn(*a, **k)) + ) + return hass + + +async def _prepare(path): + from custom_components.nestquest.core.db import NestQuestDatabase + from custom_components.nestquest.core.migrations import apply_migrations + + database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) + await database.open(path) + await apply_migrations(database) + return database + + +def _run(coro): + import asyncio + + return asyncio.new_event_loop().run_until_complete(coro) + + +def test_materialize_over_non_default_horizon(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 materialize over it; asserts the resulting instance rows land + exactly on the [today, today+3] window and NOT beyond it. + """ + import asyncio + + 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, + ) + + async def _main(): + database = await _prepare(tmp_path / "nq.db") + 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() + + asyncio.new_event_loop().run_until_complete(_main())