feat: implement options flow for post-setup settings
This commit is contained in:
@@ -14,6 +14,15 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
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(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -13,5 +13,22 @@
|
|||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "NestQuest was successfully configured."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,5 +13,22 @@
|
|||||||
"create_entry": {
|
"create_entry": {
|
||||||
"default": "NestQuest was successfully configured."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user