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.
505 lines
18 KiB
Python
505 lines
18 KiB
Python
"""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())
|