From 6760f0f7b9b25ce3b760647f4ec35f56959d4df2 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 15:51:29 -0400 Subject: [PATCH 1/3] feat: implement options flow for post-setup settings --- custom_components/nestquest/config_flow.py | 9 + custom_components/nestquest/options_flow.py | 136 +++++++ custom_components/nestquest/strings.json | 17 + .../nestquest/translations/en.json | 17 + tests/test_options_flow.py | 385 ++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 custom_components/nestquest/options_flow.py create mode 100644 tests/test_options_flow.py diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index 1d5f1ba..7efc86e 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -14,6 +14,15 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> Any: + """Create the options flow handler for NestQuest.""" + from .options_flow import NestQuestOptionsFlow + + return NestQuestOptionsFlow(config_entry=config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py new file mode 100644 index 0000000..096504c --- /dev/null +++ b/custom_components/nestquest/options_flow.py @@ -0,0 +1,136 @@ +"""Options flow for NestQuest post-setup settings.""" +from __future__ import annotations + +from typing import Any + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult + +from .const import ( + CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, + CONF_PANEL_IDLE_TIMEOUT, + DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DEFAULT_PANEL_IDLE_TIMEOUT, +) + +try: + import voluptuous as vol +except ImportError: + vol = None + +MIN_HORIZON_DAYS = 1 +MIN_PANEL_IDLE_TIMEOUT = 30 +_TIME_PART_LENGTH = 2 + + +def _parse_time(value: str) -> bool: + """Return True when value is a valid 24-hour HH:MM string.""" + parts = value.split(":") + if len(parts) != 2: + return False + hours, minutes = parts + if len(hours) != _TIME_PART_LENGTH or len(minutes) != _TIME_PART_LENGTH: + return False + if not hours.isdigit() or not minutes.isdigit(): + return False + return int(hours) <= 23 and int(minutes) <= 59 + + +def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: + """Validate user input and return field-level errors instead of raising.""" + errors: dict[str, str] = {} + + horizon_days = user_input.get(CONF_HORIZON_DAYS) + if ( + isinstance(horizon_days, bool) + or not isinstance(horizon_days, int) + or horizon_days < MIN_HORIZON_DAYS + ): + errors[CONF_HORIZON_DAYS] = "invalid" + + day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME) + if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time): + errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time" + + panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT) + if ( + isinstance(panel_idle_timeout, bool) + or not isinstance(panel_idle_timeout, int) + or panel_idle_timeout < MIN_PANEL_IDLE_TIMEOUT + ): + errors[CONF_PANEL_IDLE_TIMEOUT] = "invalid" + + return errors + + +def _build_schema(current: dict[str, Any]) -> Any: + """Build the form schema, pre-filled with the current values.""" + if vol is not None: + return vol.Schema( + { + vol.Required( + CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] + ): int, + vol.Required( + CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] + ): str, + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] + ): int, + } + ) + return { + CONF_HORIZON_DAYS: current[CONF_HORIZON_DAYS], + CONF_DAY_ROLLOVER_TIME: current[CONF_DAY_ROLLOVER_TIME], + CONF_PANEL_IDLE_TIMEOUT: current[CONF_PANEL_IDLE_TIMEOUT], + } + + +class NestQuestOptionsFlow(config_entries.OptionsFlow): + """Handle the options flow for NestQuest.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the NestQuest options.""" + if user_input is None: + return self.async_show_form( + step_id="init", data_schema=_build_schema(self._current_values()) + ) + + errors = _async_validate(user_input) + if errors: + return self.async_show_form( + step_id="init", + data_schema=_build_schema(self._current_values()), + errors=errors, + ) + + return self.async_create_entry( + title="NestQuest", + data={ + CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], + CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], + CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], + }, + ) + + def _current_values(self) -> dict[str, Any]: + """Resolve current values from entry options, then entry data, then defaults.""" + options = self.options + entry_data = getattr(self.config_entry, "data", None) or {} + values: dict[str, Any] = {} + for key, default in ( + (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), + (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), + (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), + ): + if key in options: + values[key] = options[key] + elif key in entry_data: + values[key] = entry_data[key] + else: + values[key] = default + return values \ No newline at end of file diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index e297112..277492d 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -13,5 +13,22 @@ "create_entry": { "default": "NestQuest was successfully configured." } + }, + "options": { + "step": { + "init": { + "title": "NestQuest Settings", + "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "data": { + "horizon_days": "Generation horizon (days)", + "day_rollover_time": "Day rollover time (HH:MM)", + "panel_idle_timeout": "Panel idle timeout (seconds)" + } + } + }, + "errors": { + "invalid": "Invalid value.", + "invalid_time": "Time must use 24-hour HH:MM format." + } } } \ No newline at end of file diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index e297112..277492d 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -13,5 +13,22 @@ "create_entry": { "default": "NestQuest was successfully configured." } + }, + "options": { + "step": { + "init": { + "title": "NestQuest Settings", + "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "data": { + "horizon_days": "Generation horizon (days)", + "day_rollover_time": "Day rollover time (HH:MM)", + "panel_idle_timeout": "Panel idle timeout (seconds)" + } + } + }, + "errors": { + "invalid": "Invalid value.", + "invalid_time": "Time must use 24-hour HH:MM format." + } } } \ No newline at end of file diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 0000000..4f800dc --- /dev/null +++ b/tests/test_options_flow.py @@ -0,0 +1,385 @@ +"""Tests for the options flow of NestQuest.""" +from __future__ import annotations + +import ast +import asyncio +import inspect +import json +from pathlib import Path +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock +# Mock homeassistant modules if not installed in environment +for _mod in ( + "homeassistant", + "homeassistant.core", + "homeassistant.config_entries", + "homeassistant.data_entry_flow", +): + sys.modules.setdefault(_mod, MagicMock()) + +# Wire parent package attributes to the actual module mocks so that +# "from homeassistant import config_entries" resolves to the mocked module. +sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_entries"] +sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] + + +class _OptionsFlowBase: + """Minimal stand-in for homeassistant OptionsFlow.""" + + def __init__(self, config_entry=None): + self.config_entry = config_entry + self.hass = None + self.options = dict(config_entry.options) if config_entry is not None else {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + +class _ConfigFlowBase: + """Stand-in for homeassistant ConfigFlow; a superset of test_config_flow's base. + + test_config_flow.py may be imported after this file, so the concrete + NestQuestConfigFlow can end up bound to this base. It must therefore also + satisfy that file's tests. + """ + + VERSION = 1 + + def __init_subclass__(cls, domain=None, **kwargs): + super().__init_subclass__(**kwargs) + cls._domain = domain + + def __init__(self): + self.context = {} + + def async_show_form(self, *, step_id, **kwargs): + return {"type": "form", "step_id": step_id, **kwargs} + + def async_abort(self, *, reason, **kwargs): + return {"type": "abort", "reason": reason} + + def async_create_entry(self, *, title, data, **kwargs): + return {"type": "create_entry", "title": title, "data": data} + + async def async_set_unique_id(self, unique_id, raise_on_progress=True): + self.context["unique_id"] = unique_id + return None + + def _async_current_entries(self): + return self.hass.config_entries.async_entries(self._domain) + + def _async_in_progress(self): + return [] + + @staticmethod + def async_get_options_flow(config_entry): + raise NotImplementedError + + +sys.modules["homeassistant.config_entries"].OptionsFlow = _OptionsFlowBase +sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase + +import pytest + +from custom_components.nestquest import config_flow +from custom_components.nestquest import options_flow +from custom_components.nestquest.const import ( + CONF_DAY_ROLLOVER_TIME, + CONF_HORIZON_DAYS, + CONF_PANEL_IDLE_TIMEOUT, + DEFAULT_DAY_ROLLOVER_TIME, + DEFAULT_HORIZON_DAYS, + DEFAULT_PANEL_IDLE_TIMEOUT, + DOMAIN, +) + +PKG_DIR = Path(options_flow.__file__).parent +STRINGS_PATH = PKG_DIR / "strings.json" +TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" + +VALID_INPUT = { + CONF_HORIZON_DAYS: 7, + CONF_DAY_ROLLOVER_TIME: "03:30", + CONF_PANEL_IDLE_TIMEOUT: 60, +} + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +class _ListenerRegistry: + """Models HA's update-listener bookkeeping with reload tracking.""" + + def __init__(self, hass=None): + self._hass = hass + self._listeners: dict[str, list[object]] = {} + self.reloaded: list[str] = [] + self._loop = asyncio.new_event_loop() + + def add(self, entry_id: str, listener) -> object: + def _remove(): + self._listeners.get(entry_id, []).remove(listener) + + self._listeners.setdefault(entry_id, []).append(listener) + return _remove + + def dispatch_options_update(self, entry) -> None: + for listener in list(self._listeners.get(entry.entry_id, [])): + result = listener(self._hass, entry) + if inspect.isawaitable(result): + self._loop.run_until_complete(result) + + @property + def size(self) -> int: + return sum(len(listeners) for listeners in self._listeners.values()) + + +def _make_hass(): + hass = SimpleNamespace( + data={}, + config_entries=SimpleNamespace(async_reload=None), + ) + registry = _ListenerRegistry(hass) + + async def _async_reload(entry_id: str) -> None: + registry.reloaded.append(entry_id) + + hass.config_entries.async_reload = MagicMock(side_effect=_async_reload) + return hass, registry + + +def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None): + entry = SimpleNamespace( + entry_id=entry_id, options=dict(options or {}), data=dict(data or {}) + ) + entry.add_update_listener = None + return entry + + +def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry.""" + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) + flow.hass = SimpleNamespace() + return flow + + +def test_options_flow_reachable_via_configure_button() -> None: + """The config flow exposes async_get_options_flow returning the options flow.""" + entry = _make_entry() + handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry) + assert isinstance(handler, options_flow.NestQuestOptionsFlow) + assert handler.config_entry is entry + + +def test_options_flow_shows_form_initially() -> None: + """The init step shows a form.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + assert result["type"] == "form" + assert result["step_id"] == "init" + + +def test_options_flow_prefills_from_defaults() -> None: + """Without stored options, the form pre-fills with the constant defaults.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + schema = result["data_schema"] + assert schema[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS + assert schema[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME + assert schema[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT + + +def test_options_flow_prefills_from_entry_options() -> None: + """Stored entry options win over entry data and defaults.""" + entry = _make_entry( + options={ + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + }, + data={ + CONF_HORIZON_DAYS: 99, + CONF_DAY_ROLLOVER_TIME: "23:59", + CONF_PANEL_IDLE_TIMEOUT: 999, + }, + ) + result = _run(_make_flow(entry).async_step_init(None)) + schema = result["data_schema"] + assert schema[CONF_HORIZON_DAYS] == 21 + assert schema[CONF_DAY_ROLLOVER_TIME] == "05:45" + assert schema[CONF_PANEL_IDLE_TIMEOUT] == 120 + + +def test_options_flow_falls_back_to_entry_data() -> None: + """Pre-upgrade entries without options fall back to entry.data.""" + entry = _make_entry( + data={ + CONF_HORIZON_DAYS: 10, + CONF_DAY_ROLLOVER_TIME: "01:15", + CONF_PANEL_IDLE_TIMEOUT: 90, + } + ) + result = _run(_make_flow(entry).async_step_init(None)) + schema = result["data_schema"] + assert schema[CONF_HORIZON_DAYS] == 10 + assert schema[CONF_DAY_ROLLOVER_TIME] == "01:15" + assert schema[CONF_PANEL_IDLE_TIMEOUT] == 90 + + +@pytest.mark.parametrize("bad_horizon", [0, -5]) +def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None: + """Zero or negative horizon values yield a field-level error, no exception.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"} + + +def test_options_flow_rejects_malformed_time_not_a_time() -> None: + """Non-HH:MM strings are rejected with invalid_time.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + +def test_options_flow_rejects_malformed_time_out_of_range() -> None: + """Out-of-range HH:MM values are rejected with invalid_time.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + +@pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10]) +def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: + """Non-numeric or too-low idle timeouts yield a field-level error.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_PANEL_IDLE_TIMEOUT: bad_timeout}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} + + +def test_options_flow_creates_entry_with_three_keys() -> None: + """A valid submit creates an entry with exactly the three validated keys.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["title"] == "NestQuest" + assert set(result["data"]) == { + CONF_HORIZON_DAYS, + CONF_DAY_ROLLOVER_TIME, + CONF_PANEL_IDLE_TIMEOUT, + } + assert result["data"] == VALID_INPUT + + +def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once() -> None: + """The flow's created data is written to entry.options and reloads exactly once.""" + hass, registry = _make_hass() + entry = _make_entry() + entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) + + # Simulate setup registering the merged lifecycle update listener. + from custom_components.nestquest import async_setup_entry + + _run(async_setup_entry(hass, entry)) + + # Run the options flow and apply its created data to entry.options like HA does. + flow = _make_flow(entry) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + entry.options = dict(result["data"]) + + registry.dispatch_options_update(entry) + assert hass.config_entries.async_reload.call_count == 1 + assert registry.reloaded == [entry.entry_id] + assert entry.options == VALID_INPUT + + +def test_options_flow_validation_never_raises() -> None: + """Validation returns errors instead of raising for arbitrary junk input.""" + junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None} + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(junk)) + assert result["type"] == "form" + assert result["errors"] == { + CONF_HORIZON_DAYS: "invalid", + CONF_DAY_ROLLOVER_TIME: "invalid_time", + CONF_PANEL_IDLE_TIMEOUT: "invalid", + } + + +@pytest.fixture +def strings() -> dict: + """Load and parse strings.json.""" + assert STRINGS_PATH.exists(), f"strings.json does not exist at {STRINGS_PATH}" + with open(STRINGS_PATH, encoding="utf-8") as f: + return json.load(f) + + +@pytest.fixture +def translations_en() -> dict: + """Load and parse translations/en.json.""" + assert TRANSLATIONS_EN_PATH.exists(), ( + f"translations/en.json does not exist at {TRANSLATIONS_EN_PATH}" + ) + with open(TRANSLATIONS_EN_PATH, encoding="utf-8") as f: + return json.load(f) + + +def test_options_strings_present_and_matching(strings: dict, translations_en: dict) -> None: + """Both string files carry identical options step/error strings.""" + assert strings.get("options") == translations_en.get("options") + options = strings["options"] + init = options["step"]["init"] + assert init["title"] + assert init["description"] + data = init["data"] + for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT): + assert data.get(key), f"Missing field name for '{key}'" + errors = options["errors"] + for key in ("invalid", "invalid_time"): + assert errors.get(key), f"Missing error string for '{key}'" + + +def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> None: + """No placeholder/raw keys appear in the options strings.""" + + def check(value) -> None: + if isinstance(value, dict): + for key, val in value.items(): + assert val, f"Empty string value for key '{key}'" + check(val) + elif isinstance(value, str): + assert value.strip() != "" + + check(strings["options"]) + check(translations_en["options"]) + + +def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: + """The options flow module does not hardcode the domain or db filename.""" + py_file = Path(options_flow.__file__) + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + assert node.value != DOMAIN, ( + f"Hard-coded domain string in {py_file.name}:{node.lineno}" + ) + assert node.value != "nestquest.db", ( + f"Hard-coded database filename in {py_file.name}:{node.lineno}" + ) \ No newline at end of file -- 2.55.0 From 46e27e584a3781ed4bb99f952f535d6dcc333379 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 18:22:38 -0400 Subject: [PATCH 2/3] fix: adopt OptionsFlowWithConfigEntry for HA 2024.6 compatibility and use voluptuous schema --- custom_components/nestquest/config_flow.py | 2 + custom_components/nestquest/options_flow.py | 42 ++--- pyproject.toml | 1 + tests/test_options_flow.py | 169 ++++++++++++++++++-- 4 files changed, 172 insertions(+), 42 deletions(-) diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index 7efc86e..a8ccbc2 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -15,6 +16,7 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @staticmethod + @callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, ) -> Any: diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 096504c..d5c2b56 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult @@ -15,11 +16,6 @@ from .const import ( DEFAULT_PANEL_IDLE_TIMEOUT, ) -try: - import voluptuous as vol -except ImportError: - vol = None - MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 _TIME_PART_LENGTH = 2 @@ -65,30 +61,24 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: return errors -def _build_schema(current: dict[str, Any]) -> Any: +def _build_schema(current: dict[str, Any]) -> vol.Schema: """Build the form schema, pre-filled with the current values.""" - if vol is not None: - return vol.Schema( - { - vol.Required( - CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] - ): int, - vol.Required( - CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] - ): str, - vol.Required( - CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] - ): int, - } - ) - return { - CONF_HORIZON_DAYS: current[CONF_HORIZON_DAYS], - CONF_DAY_ROLLOVER_TIME: current[CONF_DAY_ROLLOVER_TIME], - CONF_PANEL_IDLE_TIMEOUT: current[CONF_PANEL_IDLE_TIMEOUT], - } + return vol.Schema( + { + vol.Required( + CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] + ): int, + vol.Required( + CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] + ): str, + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] + ): int, + } + ) -class NestQuestOptionsFlow(config_entries.OptionsFlow): +class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Handle the options flow for NestQuest.""" async def async_step_init( diff --git a/pyproject.toml b/pyproject.toml index c34f265..d7bdfe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [] [dependency-groups] dev = [ "pytest>=8.0.0", + "voluptuous>=0.14", ] [tool.pytest.ini_options] diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 4f800dc..f8ad99b 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -3,13 +3,20 @@ from __future__ import annotations import ast import asyncio +import importlib import inspect import json from pathlib import Path import sys from types import SimpleNamespace +from typing import Any from unittest.mock import MagicMock -# Mock homeassistant modules if not installed in environment + +# Mock homeassistant modules if not installed in environment. +# The mock mirrors the REAL Home Assistant 2024.6 contract: +# config_entries.OptionsFlowWithConfigEntry accepts a config_entry kwarg and +# exposes .options / .config_entry; homeassistant.core.callback exists as a +# decorator; data_entry_flow.RESULT_TYPE_* constants and FlowResult exist. for _mod in ( "homeassistant", "homeassistant.core", @@ -24,8 +31,8 @@ sys.modules["homeassistant"].config_entries = sys.modules["homeassistant.config_ sys.modules["homeassistant"].data_entry_flow = sys.modules["homeassistant.data_entry_flow"] -class _OptionsFlowBase: - """Minimal stand-in for homeassistant OptionsFlow.""" +class _OptionsFlowWithConfigEntryBase: + """Stand-in mirroring HA 2024.6 config_entries.OptionsFlowWithConfigEntry.""" def __init__(self, config_entry=None): self.config_entry = config_entry @@ -80,8 +87,42 @@ class _ConfigFlowBase: raise NotImplementedError -sys.modules["homeassistant.config_entries"].OptionsFlow = _OptionsFlowBase -sys.modules["homeassistant.config_entries"].ConfigFlow = _ConfigFlowBase +def _callback_decorator(fn): + """Mirror homeassistant.core.callback: flags the fn and returns it unchanged.""" + fn.__ha_callback__ = True + return fn + + +_config_entries_mock = sys.modules["homeassistant.config_entries"] +_core_mock = sys.modules["homeassistant.core"] +_data_entry_flow_mock = sys.modules["homeassistant.data_entry_flow"] + +# The concrete 2024.6-shaped surface the integration is written against: +# plain OptionsFlow has no config_entry constructor (NQ-001); only +# OptionsFlowWithConfigEntry does. +_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase +_config_entries_mock.ConfigFlow = _ConfigFlowBase +_core_mock.callback = _callback_decorator + +# data_entry_flow constants/annotations referenced by the production modules. +_data_entry_flow_mock.RESULT_TYPE_FORM = "form" +_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" +_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" +_data_entry_flow_mock.FlowResult = dict + +# test_config_flow.py may run first and import the integration modules against a +# bare MagicMock surface. Force a clean re-import now that the 2024.6-shaped +# mocks are installed, so the concrete classes bind to them. +for _name in [ + name + for name in sys.modules + if name.startswith("custom_components.nestquest") +]: + del sys.modules[_name] + +importlib.import_module("custom_components.nestquest") +importlib.import_module("custom_components.nestquest.config_flow") +importlib.import_module("custom_components.nestquest.options_flow") import pytest @@ -168,6 +209,15 @@ def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: return flow +def _schema_values(schema) -> dict: + """Extract the per-field default values from a real voluptuous schema.""" + return { + marker.schema: marker.default() + for marker in schema.schema + if hasattr(marker, "schema") + } + + def test_options_flow_reachable_via_configure_button() -> None: """The config flow exposes async_get_options_flow returning the options flow.""" entry = _make_entry() @@ -176,6 +226,32 @@ def test_options_flow_reachable_via_configure_button() -> None: assert handler.config_entry is entry +def test_options_flow_get_options_flow_is_ha_callback() -> None: + """async_get_options_flow is wrapped by the homeassistant.core.callback decorator.""" + assert getattr( + config_flow.NestQuestConfigFlow.async_get_options_flow, "__ha_callback__", False + ) is True + + +def test_options_flow_constructs_through_2024_6_surface() -> None: + """The handler constructs against the mocked 2024.6 base and inherits its contract.""" + entry = _make_entry(options={CONF_HORIZON_DAYS: 21}) + flow = options_flow.NestQuestOptionsFlow(config_entry=entry) + assert isinstance(flow, sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry) + assert flow.config_entry is entry + assert flow.options == {CONF_HORIZON_DAYS: 21} + + +def test_options_flow_submits_through_2024_6_surface() -> None: + """The handler can be submitted through the mocked 2024.6-shaped surface.""" + entry = _make_entry() + handler = config_flow.NestQuestConfigFlow.async_get_options_flow(entry) + result = _run(handler.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["title"] == "NestQuest" + assert result["data"] == VALID_INPUT + + def test_options_flow_shows_form_initially() -> None: """The init step shows a form.""" flow = _make_flow(_make_entry()) @@ -184,14 +260,23 @@ def test_options_flow_shows_form_initially() -> None: assert result["step_id"] == "init" +def test_options_flow_schema_is_voluptuous() -> None: + """The form's data_schema is a real voluptuous Schema (no dict fallback).""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) + import voluptuous as vol + + assert isinstance(result["data_schema"], vol.Schema) + + def test_options_flow_prefills_from_defaults() -> None: """Without stored options, the form pre-fills with the constant defaults.""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(None)) - schema = result["data_schema"] - assert schema[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS - assert schema[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME - assert schema[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS + assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME + assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT def test_options_flow_prefills_from_entry_options() -> None: @@ -209,10 +294,10 @@ def test_options_flow_prefills_from_entry_options() -> None: }, ) result = _run(_make_flow(entry).async_step_init(None)) - schema = result["data_schema"] - assert schema[CONF_HORIZON_DAYS] == 21 - assert schema[CONF_DAY_ROLLOVER_TIME] == "05:45" - assert schema[CONF_PANEL_IDLE_TIMEOUT] == 120 + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == 21 + assert values[CONF_DAY_ROLLOVER_TIME] == "05:45" + assert values[CONF_PANEL_IDLE_TIMEOUT] == 120 def test_options_flow_falls_back_to_entry_data() -> None: @@ -225,10 +310,38 @@ def test_options_flow_falls_back_to_entry_data() -> None: } ) result = _run(_make_flow(entry).async_step_init(None)) + values = _schema_values(result["data_schema"]) + assert values[CONF_HORIZON_DAYS] == 10 + assert values[CONF_DAY_ROLLOVER_TIME] == "01:15" + assert values[CONF_PANEL_IDLE_TIMEOUT] == 90 + + +def test_options_flow_schema_applies_stored_defaults() -> None: + """A real voluptuous validate({}) fills every field from the stored defaults.""" + entry = _make_entry( + options={ + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + } + ) + result = _run(_make_flow(entry).async_step_init(None)) + assert result["data_schema"]({}) == { + CONF_HORIZON_DAYS: 21, + CONF_DAY_ROLLOVER_TIME: "05:45", + CONF_PANEL_IDLE_TIMEOUT: 120, + } + + +def test_options_flow_schema_coerces_field_types() -> None: + """The schema validates the expected field types via voluptuous.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(None)) schema = result["data_schema"] - assert schema[CONF_HORIZON_DAYS] == 10 - assert schema[CONF_DAY_ROLLOVER_TIME] == "01:15" - assert schema[CONF_PANEL_IDLE_TIMEOUT] == 90 + validators = {marker.schema: validator for marker, validator in schema.schema.items()} + assert validators[CONF_HORIZON_DAYS] is int + assert validators[CONF_DAY_ROLLOVER_TIME] is str + assert validators[CONF_PANEL_IDLE_TIMEOUT] is int @pytest.mark.parametrize("bad_horizon", [0, -5]) @@ -371,6 +484,30 @@ def test_options_strings_no_raw_keys(strings: dict, translations_en: dict) -> No check(translations_en["options"]) +def test_options_flow_no_plain_dict_schema_fallback() -> None: + """No try/except ImportError fallback for voluptuous in the options flow module.""" + py_file = Path(options_flow.__file__) + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Try): + handlers = node.handlers or [] + has_import_error_handler = any( + getattr(handler.type, "id", getattr(handler.type, "attr", None)) + == "ImportError" + for handler in handlers + ) + assert not has_import_error_handler, ( + f"Plain-dict schema fallback (try/except ImportError) found in {py_file.name}:{node.lineno}" + ) + + +def test_options_flow_subclasses_options_flow_with_config_entry() -> None: + """NestQuestOptionsFlow subclasses config_entries.OptionsFlowWithConfigEntry.""" + base = sys.modules["homeassistant.config_entries"].OptionsFlowWithConfigEntry + assert issubclass(options_flow.NestQuestOptionsFlow, base) + assert options_flow.NestQuestOptionsFlow.__bases__ == (base,) + + def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: """The options flow module does not hardcode the domain or db filename.""" py_file = Path(options_flow.__file__) -- 2.55.0 From f8c91ae3fc5c0e2494f2b69c23247fef371aecbf Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 18:24:53 -0400 Subject: [PATCH 3/3] fix: validate rollover time with ascii-bounded pattern --- custom_components/nestquest/options_flow.py | 14 ++++---------- tests/test_options_flow.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index d5c2b56..eeacafb 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -1,6 +1,7 @@ """Options flow for NestQuest post-setup settings.""" from __future__ import annotations +import re from typing import Any import voluptuous as vol @@ -18,20 +19,13 @@ from .const import ( MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 -_TIME_PART_LENGTH = 2 + +_TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") def _parse_time(value: str) -> bool: """Return True when value is a valid 24-hour HH:MM string.""" - parts = value.split(":") - if len(parts) != 2: - return False - hours, minutes = parts - if len(hours) != _TIME_PART_LENGTH or len(minutes) != _TIME_PART_LENGTH: - return False - if not hours.isdigit() or not minutes.isdigit(): - return False - return int(hours) <= 23 and int(minutes) <= 59 + return _TIME_PATTERN.fullmatch(value) is not None def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index f8ad99b..71d73c4 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -375,6 +375,16 @@ def test_options_flow_rejects_malformed_time_out_of_range() -> None: assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} +def test_options_flow_rejects_pasted_unicode_superscript_time() -> None: + """Pasted Unicode digits like '²²:²²' yield a field-level error, no exception.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} + + @pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10]) def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: """Non-numeric or too-low idle timeouts yield a field-level error.""" -- 2.55.0