Files
NestQuest/tests/test_config_flow.py
T
overseer 43f68fb7a3 Address PR #142 review: core isolation invariant and shim fixes
- Point recurrence purity scan at custom_components.nestquest.core.recurrence
  (P2.1); the shim only re-exports, so the real model was no longer scanned.
- Make test_const/test_config_flow/test_dao_matrix module scans recursive
  (rglob) so custom_components/nestquest/core/ is covered (P2.2, P2.3);
  const.py name exclusion retained.
- Add test_core_isolation: AST-scan every core module for homeassistant
  imports, relative imports climbing out of core (level > 1), absolute
  imports reaching custom_components.nestquest.<not core>, and bare
  'import core'/'from core' (P2.4).
- Drop the private re-export of _month_end/_nth_weekday_of_month from the
  recurrence shim; those are test-only, so move the two test imports to
  custom_components.nestquest.core.recurrence (P2.1/P3.1).
- Fix shim docstrings and core/__init__.py to reference
  custom_components.nestquest.core.* instead of the unimportable 'core.*'
  paths (P3.2).
- Note the migrations logger namespace move to
  custom_components.nestquest.core.migrations in RELEASE_NOTES.md (P3.3).
2026-09-21 14:43:43 -04:00

179 lines
6.8 KiB
Python

"""Tests for the config flow of NestQuest."""
from __future__ import annotations
import ast
import json
from pathlib import Path
import pytest
from custom_components.nestquest import config_flow
from custom_components.nestquest.const import DOMAIN
PKG_DIR = Path(config_flow.__file__).parent
STRINGS_PATH = PKG_DIR / "strings.json"
TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json"
async def test_flow_shows_form_initially(hass, make_flow) -> None:
"""The initial user step shows a form."""
flow = make_flow(hass)
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
async def test_flow_creates_entry_on_submit(hass, make_flow) -> None:
"""Submitting the user step returns a create-entry result with the payload.
Real HA's ConfigFlow.async_create_entry only returns the flow result; the
flow manager persists the entry afterward. The manager surface therefore
stays empty here — persistence is the manager's job, not the flow's.
"""
flow = make_flow(hass)
assert hass.config_entries.async_entries(DOMAIN) == []
result = await flow.async_step_user({})
assert result["type"] == "create_entry"
assert result["title"] == "NestQuest"
assert result["data"] == {}
assert hass.config_entries.async_entries(DOMAIN) == []
async def test_second_flow_aborts_single_instance(
hass, make_flow, make_config_flow_entry
) -> None:
"""A second flow aborts with single_instance_allowed."""
existing = make_config_flow_entry(DOMAIN)
flow = make_flow(hass, existing_entries=[existing])
assert hass.config_entries.async_entries(DOMAIN) == [existing]
result = await flow.async_step_user(None)
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
assert hass.config_entries.async_entries(DOMAIN) == [existing]
async def test_flow_aborts_when_in_progress(hass, make_flow) -> None:
"""A concurrent flow in progress aborts with single_instance_allowed."""
flow = make_flow(hass, in_progress=[{"flow_id": "other"}])
result = await flow.async_step_user(None)
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
async def test_flow_sets_stable_unique_id(hass, make_flow) -> None:
"""The flow assigns a stable unique id equal to the domain."""
flow = make_flow(hass)
assert flow.context.get("unique_id") is None
await flow.async_step_user(None)
assert flow.context["unique_id"] == DOMAIN
async def test_flow_ignores_other_domain_entries(hass, make_flow, make_config_flow_entry) -> None:
"""Entries of other domains do not trigger the abort."""
flow = make_flow(hass, existing_entries=[make_config_flow_entry("other_domain")])
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
@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)
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
def test_flow_strings_valid_json(fixture_name: str, request: pytest.FixtureRequest) -> None:
"""strings.json and translations/en.json are valid JSON objects."""
content = request.getfixturevalue(fixture_name)
assert isinstance(content, dict)
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
def test_flow_strings_structure(fixture_name: str, request: pytest.FixtureRequest) -> None:
"""Config flow strings contain title, user step, and abort reasons."""
content = request.getfixturevalue(fixture_name)
assert content.get("title")
config = content.get("config", {})
assert config.get("step", {}).get("user", {}).get("title")
assert config.get("step", {}).get("user", {}).get("description")
abort = config.get("abort", {})
assert "single_instance_allowed" in abort
assert abort["single_instance_allowed"]
@pytest.mark.parametrize("fixture_name", ["strings", "translations_en"])
def test_flow_strings_no_raw_keys(fixture_name: str, request: pytest.FixtureRequest) -> None:
"""No placeholder/raw keys appear in the flow strings."""
content = request.getfixturevalue(fixture_name)
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(content)
def test_flow_strings_match_translations(
strings: dict, translations_en: dict
) -> None:
"""strings.json and translations/en.json carry identical flow strings."""
assert strings == translations_en
def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None:
"""Ensure no module other than const.py hardcodes the domain or db filename."""
pkg_dir = Path(config_flow.__file__).parent
py_files = [f for f in pkg_dir.rglob("*.py") if f.name not in ("const.py",)]
assert len(py_files) > 0, "No python modules found in package to check"
for py_file in py_files:
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):
if node.value == "nestquest":
raise AssertionError(
f"Found hard-coded domain string in {py_file.name}:{node.lineno}"
)
assert node.value != "nestquest.db", (
f"Found hard-coded database filename in {py_file.name}:{node.lineno}"
)
async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None:
"""The HA user completing the flow lands in entry data as the
initial admin copy: entry.context is not persisted by real HA, so
the durable record must carry the owner."""
from custom_components.nestquest.const import CONF_ADMIN_USER_IDS
flow = make_flow(hass)
flow.context = {"user_id": "ha-owner"}
result = await flow.async_step_user({})
assert result["type"] == "create_entry"
assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]}
async def test_flow_without_context_user_keeps_data_untouched(
hass, make_flow
) -> None:
flow = make_flow(hass)
result = await flow.async_step_user({"horizon_days": 7})
assert result["type"] == "create_entry"
assert result["data"] == {"horizon_days": 7}