2026-09-13 18:26:09 -04:00
|
|
|
"""Tests for the options flow of NestQuest."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import ast
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-09-13 19:18:10 -04:00
|
|
|
from conftest import make_config_entry, make_hass, options_flow_base as _options_flow_base
|
|
|
|
|
|
2026-09-13 18:26:09 -04:00
|
|
|
from custom_components.nestquest import config_flow
|
|
|
|
|
from custom_components.nestquest import options_flow
|
|
|
|
|
from custom_components.nestquest.const import (
|
2026-09-23 05:12:27 -04:00
|
|
|
CONF_API_BASE_URL,
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT,
|
|
|
|
|
CONF_PANEL_TOKEN,
|
2026-09-23 08:08:06 -04:00
|
|
|
CONF_SNAPSHOT_STALENESS,
|
2026-09-23 05:12:27 -04:00
|
|
|
CONF_UPDATE_INTERVAL,
|
|
|
|
|
DEFAULT_API_BASE_URL,
|
2026-09-23 09:01:21 -04:00
|
|
|
DEFAULT_PANEL_IDLE_TIMEOUT,
|
2026-09-23 05:12:27 -04:00
|
|
|
DEFAULT_PANEL_TOKEN,
|
2026-09-23 08:08:06 -04:00
|
|
|
DEFAULT_SNAPSHOT_STALENESS,
|
2026-09-19 11:41:12 -04:00
|
|
|
DEFAULT_UPDATE_INTERVAL,
|
2026-09-13 18:26:09 -04:00
|
|
|
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_PANEL_IDLE_TIMEOUT: 60,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 14:18:18 -04:00
|
|
|
#: What a schema-validated submit produces from a bare VALID_INPUT on a
|
2026-09-23 09:01:21 -04:00
|
|
|
#: fresh entry: the Feature 10 refresh interval, the Feature 18
|
|
|
|
|
#: staleness threshold, and the Feature 18 API connection fill from
|
2026-09-23 05:12:27 -04:00
|
|
|
#: their defaults.
|
2026-09-19 14:18:18 -04:00
|
|
|
FULL_INPUT = {
|
|
|
|
|
**VALID_INPUT,
|
|
|
|
|
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
|
2026-09-23 08:08:06 -04:00
|
|
|
CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS,
|
2026-09-23 05:12:27 -04:00
|
|
|
CONF_API_BASE_URL: DEFAULT_API_BASE_URL,
|
|
|
|
|
CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN,
|
2026-09-19 14:18:18 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
def _run(coro):
|
|
|
|
|
return asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_hass():
|
2026-09-13 19:18:10 -04:00
|
|
|
return make_hass()
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: dict | None = None):
|
2026-09-13 19:18:10 -04:00
|
|
|
return make_config_entry(entry_id=entry_id, options=options, data=data)
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
2026-09-23 09:01:21 -04:00
|
|
|
def _make_flow(entry) -> options_flow.NestQuestOptionsFlow:
|
|
|
|
|
"""Create an options flow instance bound to a stubbed config entry."""
|
2026-09-13 18:26:09 -04:00
|
|
|
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
|
|
|
|
flow.hass = SimpleNamespace()
|
|
|
|
|
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()
|
|
|
|
|
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_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."""
|
2026-09-23 09:01:21 -04:00
|
|
|
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90})
|
2026-09-13 18:26:09 -04:00
|
|
|
flow = options_flow.NestQuestOptionsFlow(config_entry=entry)
|
2026-09-13 19:18:10 -04:00
|
|
|
assert isinstance(flow, _options_flow_base())
|
2026-09-13 18:26:09 -04:00
|
|
|
assert flow.config_entry is entry
|
2026-09-23 09:01:21 -04:00
|
|
|
assert flow.options == {CONF_UPDATE_INTERVAL: 90}
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-09-19 14:18:18 -04:00
|
|
|
assert result["data"] == FULL_INPUT
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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_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))
|
|
|
|
|
values = _schema_values(result["data_schema"])
|
|
|
|
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT
|
2026-09-23 09:01:21 -04:00
|
|
|
assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL
|
|
|
|
|
assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_prefills_from_entry_options() -> None:
|
|
|
|
|
"""Stored entry options win over entry data and defaults."""
|
|
|
|
|
entry = _make_entry(
|
|
|
|
|
options={
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_UPDATE_INTERVAL: 90,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 450,
|
2026-09-13 18:26:09 -04:00
|
|
|
},
|
|
|
|
|
data={
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: 999,
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_UPDATE_INTERVAL: 999,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 999,
|
2026-09-13 18:26:09 -04:00
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
result = _run(_make_flow(entry).async_step_init(None))
|
|
|
|
|
values = _schema_values(result["data_schema"])
|
|
|
|
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == 120
|
2026-09-23 09:01:21 -04:00
|
|
|
assert values[CONF_UPDATE_INTERVAL] == 90
|
|
|
|
|
assert values[CONF_SNAPSHOT_STALENESS] == 450
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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_PANEL_IDLE_TIMEOUT: 90,
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_UPDATE_INTERVAL: 60,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 240,
|
2026-09-13 18:26:09 -04:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
result = _run(_make_flow(entry).async_step_init(None))
|
|
|
|
|
values = _schema_values(result["data_schema"])
|
|
|
|
|
assert values[CONF_PANEL_IDLE_TIMEOUT] == 90
|
2026-09-23 09:01:21 -04:00
|
|
|
assert values[CONF_UPDATE_INTERVAL] == 60
|
|
|
|
|
assert values[CONF_SNAPSHOT_STALENESS] == 240
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_schema_applies_stored_defaults() -> None:
|
2026-09-23 09:01:21 -04:00
|
|
|
"""A real voluptuous validate({}) fills every field from the stored defaults."""
|
2026-09-13 18:26:09 -04:00
|
|
|
entry = _make_entry(
|
|
|
|
|
options={
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
2026-09-19 11:41:12 -04:00
|
|
|
CONF_UPDATE_INTERVAL: 90,
|
2026-09-23 08:08:06 -04:00
|
|
|
CONF_SNAPSHOT_STALENESS: 450,
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
|
|
|
|
CONF_PANEL_TOKEN: "stored-token",
|
2026-09-13 18:26:09 -04:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
result = _run(_make_flow(entry).async_step_init(None))
|
|
|
|
|
assert result["data_schema"]({}) == {
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: 120,
|
2026-09-19 11:41:12 -04:00
|
|
|
CONF_UPDATE_INTERVAL: 90,
|
2026-09-23 08:08:06 -04:00
|
|
|
CONF_SNAPSHOT_STALENESS: 450,
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
|
|
|
|
CONF_PANEL_TOKEN: "stored-token",
|
2026-09-13 18:26:09 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
validators = {marker.schema: validator for marker, validator in schema.schema.items()}
|
|
|
|
|
assert validators[CONF_PANEL_IDLE_TIMEOUT] is int
|
2026-09-23 09:01:21 -04:00
|
|
|
assert validators[CONF_UPDATE_INTERVAL] is int
|
|
|
|
|
assert validators[CONF_SNAPSHOT_STALENESS] is int
|
2026-09-23 05:12:27 -04:00
|
|
|
# The Feature 18 API connection fields are free-text strings.
|
|
|
|
|
assert validators[CONF_API_BASE_URL] is str
|
|
|
|
|
assert validators[CONF_PANEL_TOKEN] is str
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"}
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 09:01:21 -04:00
|
|
|
@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29])
|
|
|
|
|
def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None:
|
|
|
|
|
"""Non-numeric, boolean, and below-floor update intervals reject."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval})
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"}
|
|
|
|
|
|
|
|
|
|
|
2026-09-23 05:12:27 -04:00
|
|
|
def test_options_flow_creates_entry_with_validated_keys() -> None:
|
|
|
|
|
"""A valid submit creates an entry with exactly the validated keys
|
2026-09-23 09:01:21 -04:00
|
|
|
(the client-facing settings, filled from defaults where the raw
|
|
|
|
|
submit omitted them)."""
|
2026-09-13 18:26:09 -04:00
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["title"] == "NestQuest"
|
2026-09-19 14:18:18 -04:00
|
|
|
assert set(result["data"]) == set(FULL_INPUT)
|
|
|
|
|
assert result["data"] == FULL_INPUT
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-09-23 09:01:21 -04:00
|
|
|
# Simulate setup registering the lifecycle update listener.
|
2026-09-13 18:26:09 -04:00
|
|
|
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"])
|
|
|
|
|
|
2026-09-13 19:18:10 -04:00
|
|
|
_run(registry.dispatch_options_update(entry))
|
2026-09-13 18:26:09 -04:00
|
|
|
assert hass.config_entries.async_reload.call_count == 1
|
|
|
|
|
assert registry.reloaded == [entry.entry_id]
|
2026-09-19 14:18:18 -04:00
|
|
|
assert entry.options == FULL_INPUT
|
2026-09-13 18:26:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_validation_never_raises() -> None:
|
|
|
|
|
"""Validation returns errors instead of raising for arbitrary junk input."""
|
2026-09-23 09:01:21 -04:00
|
|
|
junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"}
|
2026-09-13 18:26:09 -04:00
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(flow.async_step_init(junk))
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {
|
|
|
|
|
CONF_PANEL_IDLE_TIMEOUT: "invalid",
|
2026-09-23 09:01:21 -04:00
|
|
|
CONF_UPDATE_INTERVAL: "invalid",
|
2026-09-13 18:26:09 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"]
|
2026-09-23 09:01:21 -04:00
|
|
|
for key in (CONF_PANEL_IDLE_TIMEOUT,):
|
2026-09-13 18:26:09 -04:00
|
|
|
assert data.get(key), f"Missing field name for '{key}'"
|
|
|
|
|
errors = options["errors"]
|
2026-09-23 09:01:21 -04:00
|
|
|
for key in ("invalid",):
|
2026-09-13 18:26:09 -04:00
|
|
|
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_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."""
|
2026-09-13 19:18:10 -04:00
|
|
|
base = _options_flow_base()
|
2026-09-13 18:26:09 -04:00
|
|
|
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__)
|
|
|
|
|
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}"
|
2026-09-15 17:03:53 -04:00
|
|
|
)
|
|
|
|
|
|
2026-09-23 05:12:27 -04:00
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Panel-plane API connection section (Feature 18)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_api_section_defaults_saved() -> None:
|
|
|
|
|
"""A bare submit saves the API connection defaults: the const base
|
|
|
|
|
URL and an EMPTY panel token (no token is ever invented)."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL
|
|
|
|
|
assert result["data"][CONF_PANEL_TOKEN] == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_api_base_url_must_be_full_http_url() -> None:
|
|
|
|
|
"""Scheme-less, host-less, empty, and non-string base URLs reject
|
|
|
|
|
with a field error — the same rule the client's constructor
|
|
|
|
|
enforces, so a malformed URL fails at the form, not at the first
|
|
|
|
|
request."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
for bad_url in ("api.lan:8000", "http://", "", " ", 42):
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init(
|
|
|
|
|
{**VALID_INPUT, CONF_API_BASE_URL: bad_url}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_panel_token_validation() -> None:
|
|
|
|
|
"""The EMPTY token is valid (the API connection is simply not
|
|
|
|
|
configured yet); padded and non-string values reject — a padded
|
|
|
|
|
paste would fail the token check at request time instead."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""})
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_PANEL_TOKEN] == ""
|
|
|
|
|
|
|
|
|
|
for bad_token in (" secret ", " ", 42):
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init(
|
|
|
|
|
{**VALID_INPUT, CONF_PANEL_TOKEN: bad_token}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_api_section_stored_values_round_trip() -> None:
|
|
|
|
|
"""Stored API settings are the form's defaults and a raw legacy
|
|
|
|
|
submit without them keeps the stored values."""
|
|
|
|
|
entry = _make_entry(
|
|
|
|
|
options={
|
|
|
|
|
**VALID_INPUT,
|
|
|
|
|
CONF_API_BASE_URL: "http://panel.lan:8443",
|
|
|
|
|
CONF_PANEL_TOKEN: "stored-token",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
flow = _make_flow(entry)
|
|
|
|
|
form = _run(flow.async_step_init(None))
|
|
|
|
|
schema_defaults = form["data_schema"]({})
|
|
|
|
|
assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443"
|
|
|
|
|
assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token"
|
|
|
|
|
|
2026-09-23 09:01:21 -04:00
|
|
|
# A raw submit carrying only the panel-idle key keeps the stored
|
2026-09-23 05:12:27 -04:00
|
|
|
# API connection settings.
|
|
|
|
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443"
|
|
|
|
|
assert result["data"][CONF_PANEL_TOKEN] == "stored-token"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_strings_cover_api_section(
|
|
|
|
|
strings: dict, translations_en: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Both string files name the API fields and the URL error."""
|
|
|
|
|
data = strings["options"]["step"]["init"]["data"]
|
|
|
|
|
for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN):
|
|
|
|
|
assert data.get(key), f"Missing field name for '{key}'"
|
|
|
|
|
assert strings["options"]["errors"]["invalid_url"]
|
|
|
|
|
assert translations_en["options"] == strings["options"]
|
2026-09-23 08:08:06 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Last-good snapshot staleness section (Feature 18)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_below_update_interval_refused() -> None:
|
|
|
|
|
"""A staleness threshold shorter than the submit's update interval
|
|
|
|
|
is refused with the dedicated error: such a threshold could never
|
|
|
|
|
serve even one cached snapshot."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init(
|
|
|
|
|
{
|
|
|
|
|
**VALID_INPUT,
|
|
|
|
|
CONF_UPDATE_INTERVAL: 300,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 60,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_below_stored_interval_refused() -> None:
|
|
|
|
|
"""A raw submit setting the staleness but omitting the update
|
|
|
|
|
interval is checked against the STORED interval, not the default."""
|
|
|
|
|
entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300})
|
|
|
|
|
flow = _make_flow(entry)
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60})
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_must_be_positive_int() -> None:
|
|
|
|
|
"""Non-int, boolean, and non-positive staleness values reject with
|
|
|
|
|
the plain invalid error."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
for bad_staleness in ("900", True, 0, -10):
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init(
|
|
|
|
|
{**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "form"
|
|
|
|
|
assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_equal_to_interval_saves() -> None:
|
|
|
|
|
"""A threshold exactly as long as the update interval is valid (the
|
|
|
|
|
boundary is inclusive)."""
|
|
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(
|
|
|
|
|
flow.async_step_init(
|
|
|
|
|
{
|
|
|
|
|
**VALID_INPUT,
|
|
|
|
|
CONF_UPDATE_INTERVAL: 300,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 300,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == 300
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_defaults_saved() -> None:
|
|
|
|
|
"""A bare submit saves the staleness default (three update
|
2026-09-23 09:01:21 -04:00
|
|
|
intervals) alongside the other defaults."""
|
2026-09-23 08:08:06 -04:00
|
|
|
flow = _make_flow(_make_entry())
|
|
|
|
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_staleness_stored_values_round_trip() -> None:
|
|
|
|
|
"""A stored staleness is the form's default and a raw legacy submit
|
|
|
|
|
without it keeps the stored value."""
|
|
|
|
|
entry = _make_entry(
|
|
|
|
|
options={
|
|
|
|
|
**VALID_INPUT,
|
|
|
|
|
CONF_UPDATE_INTERVAL: 300,
|
|
|
|
|
CONF_SNAPSHOT_STALENESS: 1200,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
flow = _make_flow(entry)
|
|
|
|
|
form = _run(flow.async_step_init(None))
|
|
|
|
|
schema_defaults = form["data_schema"]({})
|
|
|
|
|
assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200
|
|
|
|
|
|
2026-09-23 09:01:21 -04:00
|
|
|
# A raw submit carrying only the panel-idle key keeps the stored
|
2026-09-23 08:08:06 -04:00
|
|
|
# staleness (and never trips the interval cross-check).
|
|
|
|
|
result = _run(flow.async_step_init(dict(VALID_INPUT)))
|
|
|
|
|
assert result["type"] == "create_entry"
|
|
|
|
|
assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_options_flow_strings_cover_staleness_section(
|
|
|
|
|
strings: dict, translations_en: dict
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Both string files name the staleness field and its error."""
|
|
|
|
|
data = strings["options"]["step"]["init"]["data"]
|
|
|
|
|
assert data.get(CONF_SNAPSHOT_STALENESS), (
|
|
|
|
|
f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'"
|
|
|
|
|
)
|
|
|
|
|
assert strings["options"]["errors"]["invalid_staleness"]
|
|
|
|
|
assert translations_en["options"] == strings["options"]
|