Replace HA config reads with an explicit settings object (task 4dd8f870)
Add core/settings.py: a frozen, HA-free NestQuestSettings dataclass carrying horizon_days, day_rollover_time, notify_target, the three notification times, and the four enable toggles. from_options() reads the CONF_* keys with the matching defaults and validates (horizon int >= 1, strict HH:MM times, real booleans); horizon_window(today) returns the rolling [today, today+horizon_days] window. The integration builds the settings object once from entry.options and threads it into the horizon materialization and daily-rollover listener; core never reads HA config itself. A settings.py shim re-exports core.settings so the integration keeps its shim-only import convention. Tests: an AST scan asserts no core module imports homeassistant or accesses hass.config / entry.options / entry.data; a from_options test covers defaults-when-absent and rejection of invalid horizon/rollover; a materialization test drives a non-default horizon (3 days, 03:30 rollover) and asserts the resulting instance window matches.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.<anything> — 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())
|
||||
Reference in New Issue
Block a user