diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 5bfea62..7d01021 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -24,7 +24,7 @@ from .const import ( LOGGER, PLATFORMS, ) -from .settings import NestQuestSettings +from .core.settings import NestQuestSettings from .frontend import async_register_frontend from .coordinator import NestQuestCoordinator from .db import NestQuestDatabase, make_database @@ -237,7 +237,6 @@ 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] @@ -270,7 +269,7 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: async def _run_horizon_materialization( hass: HomeAssistant, database: NestQuestDatabase, - settings: NestQuestSettings | None = None, + settings: NestQuestSettings, ) -> None: """Materialize the rolling horizon ``[today, today + horizon_days]``. @@ -281,14 +280,12 @@ 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. ``settings`` carries the entry's - validated ``horizon_days``; when omitted a default - :class:`NestQuestSettings` is used (its horizon is the configured - default). The window itself comes from :meth:`settings.horizon_window` - so the helper is real and shared (not recomputed inline here). + 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). """ - if settings is None: - settings = NestQuestSettings() time_zone = ZoneInfo(hass.config.time_zone) today = datetime.datetime.now(time_zone).date() start, end = settings.horizon_window(today) @@ -329,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) @@ -505,7 +504,6 @@ 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, diff --git a/custom_components/nestquest/core/settings.py b/custom_components/nestquest/core/settings.py index 25d5e57..c432335 100644 --- a/custom_components/nestquest/core/settings.py +++ b/custom_components/nestquest/core/settings.py @@ -142,13 +142,81 @@ def _resolve_optional_str(value: Any) -> str: 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`). + :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. @@ -181,14 +249,20 @@ class NestQuestSettings: # 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, - # toggle, or notify target raises here rather than later blowing - # up horizon_window or the rollover listener. + # 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") - if not isinstance(self.notify_target, str): - raise ValueError( - f"notify_target must be a string, got {self.notify_target!r}" - ) + # ``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" @@ -259,66 +333,10 @@ class NestQuestSettings: 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=_validate_hhmm( - _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=_validate_hhmm( - _option_value( - opts, - CONF_MORNING_SUMMARY_TIME, - DEFAULT_MORNING_SUMMARY_TIME, - ), - field="morning_summary_time", - ), - afternoon_reminder_time=_validate_hhmm( - _option_value( - opts, - CONF_AFTERNOON_REMINDER_TIME, - DEFAULT_AFTERNOON_REMINDER_TIME, - ), - field="afternoon_reminder_time", - ), - end_of_day_report_time=_validate_hhmm( - _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 - ) - ), - afternoon_reminder_enabled=_resolve_bool( - _option_value( - opts, - CONF_AFTERNOON_REMINDER_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - ) - ), - end_of_day_report_enabled=_resolve_bool( - _option_value( - opts, - CONF_END_OF_DAY_REPORT_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - ) - ), - celebration_enabled=_resolve_bool( - _option_value( - opts, CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED - ) - ), + **{ + field: validator(_option_value(opts, key, default)) + for field, key, default, validator in _FIELDS + } ) @classmethod @@ -345,13 +363,10 @@ class NestQuestSettings: MappingProxyType({}) if options is None else MappingProxyType(options) ) resolved: dict[str, Any] = {} - - def _resolve( - field: str, key: str, default: Any, fn: Callable[[Any], Any] - ) -> None: + for field, key, default, validator in _FIELDS: raw = _option_value(opts, key, default) try: - resolved[field] = fn(raw) + resolved[field] = validator(raw) except ValueError as err: LOGGER.warning( "NestQuest option %s is invalid (%s); falling back " @@ -361,65 +376,4 @@ class NestQuestSettings: default, ) resolved[field] = default - - _resolve( - "horizon_days", - CONF_HORIZON_DAYS, - DEFAULT_HORIZON_DAYS, - _resolve_horizon_days, - ) - _resolve( - "day_rollover_time", - CONF_DAY_ROLLOVER_TIME, - DEFAULT_DAY_ROLLOVER_TIME, - lambda v: _validate_hhmm(v, field="day_rollover_time"), - ) - _resolve( - "notify_target", - CONF_NOTIFY_TARGET, - "", - _resolve_optional_str, - ) - _resolve( - "morning_summary_time", - CONF_MORNING_SUMMARY_TIME, - DEFAULT_MORNING_SUMMARY_TIME, - lambda v: _validate_hhmm(v, field="morning_summary_time"), - ) - _resolve( - "afternoon_reminder_time", - CONF_AFTERNOON_REMINDER_TIME, - DEFAULT_AFTERNOON_REMINDER_TIME, - lambda v: _validate_hhmm(v, field="afternoon_reminder_time"), - ) - _resolve( - "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"), - ) - _resolve( - "morning_summary_enabled", - CONF_MORNING_SUMMARY_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - _resolve_bool, - ) - _resolve( - "afternoon_reminder_enabled", - CONF_AFTERNOON_REMINDER_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - _resolve_bool, - ) - _resolve( - "end_of_day_report_enabled", - CONF_END_OF_DAY_REPORT_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - _resolve_bool, - ) - _resolve( - "celebration_enabled", - CONF_CELEBRATION_ENABLED, - DEFAULT_AUTOMATION_ENABLED, - _resolve_bool, - ) return cls(**resolved) diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 0c66635..2ce49dd 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -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]: diff --git a/custom_components/nestquest/settings.py b/custom_components/nestquest/settings.py deleted file mode 100644 index 9e8f62a..0000000 --- a/custom_components/nestquest/settings.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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 index c60efe2..c07f456 100644 --- a/tests/test_core_settings.py +++ b/tests/test_core_settings.py @@ -353,6 +353,19 @@ def test_direct_construction_rejects_non_int_horizon(value: Any) -> None: +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() @@ -440,6 +453,22 @@ _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 = "" @@ -450,14 +479,24 @@ def _scan_source_for_ha_config( chain check missed): - 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 the old check could not see); - any attribute access whose ``attr`` is ``config``/``options``/ - ``data`` (catches ``hass.config``, ``config_entry.options``, - ``self.entry.options``, ``runtime.options`` regardless of root); + ``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``, which the old root-only check missed. 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). @@ -501,6 +540,16 @@ def _scan_source_for_ha_config( 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( @@ -512,6 +561,19 @@ def _scan_source_for_ha_config( 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: @@ -543,7 +605,13 @@ def test_core_modules_have_no_ha_config_reads() -> None: config-entry attribute reads ``.config``/``.options``/``.data``, and dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at ``self`` or ``runtime`` (which the old root-only check missed) is now - caught. Docstring mentions pass: AST only sees code. + 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" @@ -570,12 +638,48 @@ def test_ast_scan_flags_each_bypass_shape() -> None: "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. diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index cb793f0..8955c3f 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -36,12 +36,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) @@ -534,7 +534,7 @@ def test_from_options_horizon_validates_and_defaults() -> None: a malformed value RAISES (the integration's ``from_options_resilient`` handles the per-field fall-back at setup, not this strict path). """ - from custom_components.nestquest.settings import NestQuestSettings + from custom_components.nestquest.core.settings import NestQuestSettings assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5 assert ( diff --git a/tests/test_timezones.py b/tests/test_timezones.py index e775429..09d8271 100644 --- a/tests/test_timezones.py +++ b/tests/test_timezones.py @@ -14,6 +14,7 @@ from types import SimpleNamespace from zoneinfo import ZoneInfo 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 @@ -147,7 +148,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 +190,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 +236,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 +287,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(),