feat(1.8): Config-Flow (Test-Abruf, 4 Fehler) + Entry-Lebenszyklus

Story 1.8 (TDD, phcc, Suite gruen 105/105):
- config_flow.py: EntitySelector + Offset, sofortiger Test-Abruf, 4 unterscheidbare
  uebersetzte Fehler (no_weather_entity/no_forecast/forecast_too_short/temporary).
- __init__.py: async_setup/unload_entry, Coordinator + Zeit-/Entity-Listener leak-frei
  via async_on_unload, update_listener->reload, PLATFORMS=[] (Sensor kommt 1.9).
- translations/{en,de}.json (inkrementell, AD-13); requirements-test.txt (yanked-Pin
  + frontend); conftest hass_storage gegen Lingering-Timer.
- luna-pro-Story-Review: 2/3 Findings uebernommen, 1 verworfen (Evidenz im Ledger).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nora 2026-07-13 14:15:45 +00:00
parent e11e320093
commit 95a077f0c1
8 changed files with 385 additions and 4 deletions

View file

@ -257,3 +257,10 @@ Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale.
6 Findings, 5 übernommen (F2+F3 einheitlicher Fehler-Contract für Abruf- UND no-coverage-Fehler;
F4 changed=stored!=sig; F5 _bool-Coercion; F6 parse_switchover tz strippen), 1 verworfen mit Evidenz
(F1 `datetime.time()` ist naiv, kein TypeError — Reviewer verwechselt mit `.timetz()`).
- **1.8 Config-Flow + Entry-Lebenszyklus** ✅ — 105 Tests grün (phcc). `config_flow.py`, `__init__.py`,
`translations/{en,de}.json`, `requirements-test.txt` + `tests/test_config_flow.py`. Test-Abruf mit
4 übersetzten Fehlern, single_config_entry, Setup/Unload/Listener leak-frei (PLATFORMS=[]), Doku-Links
in Meldungen. **Umgebungs-Fix:** `home-assistant-frontend==20250306.0` nötig (frontend-Dependency);
phcc-`hass_storage`-Fixture gegen Lingering-Timer. **luna-pro-Review:** 3 Findings, 2 übernommen
(F1 breitere Fehlerklassifikation→temporary; F2 Refresh auch bei entfernter Entität), 1 verworfen mit
Evidenz (F3 HA räumt on_unload bei Setup-Fehler selbst; PLATFORMS=[] kann nicht fehlschlagen).

View file

@ -1,10 +1,90 @@
"""What to Wear — a Home Assistant custom integration.
Story 1.1 provides only the package skeleton and the pure-core model/constants.
The config-entry lifecycle (``async_setup_entry`` etc.) arrives in Story 1.8.
Entry lifecycle (Story 1.8): sets up the coordinator and its listeners without
waiting on the weather (the sensor, added in Story 1.9, always exists). All
listeners are registered through ``entry.async_on_unload`` so unload cancels
them leak-free. Sensor platform forwarding is switched on in Story 1.9.
"""
from __future__ import annotations
from .const import DOMAIN
import logging
__all__ = ["DOMAIN"]
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_change,
)
from .const import CONF_WEATHER_ENTITY, DOMAIN, default_options
from .coordinator import WTWCoordinator
from .logic.schedule import parse_switchover
from .weather.ha_entity import HAEntityProvider
_LOGGER = logging.getLogger(__name__)
# Sensor platform forwarding is enabled in Story 1.9; 1.8 sets up only the
# coordinator + listener lifecycle.
PLATFORMS: list[Platform] = []
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
provider = HAEntityProvider(hass, entry.data[CONF_WEATHER_ENTITY])
coordinator = WTWCoordinator(hass, entry, provider)
# Do not wait on the weather: the coordinator's error contract yields a
# fehler_prognose recommendation instead of blocking setup (AD-7).
await coordinator.async_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
_register_switchover_listener(hass, entry, coordinator)
_register_entity_listener(hass, entry, coordinator)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
return unloaded
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Options changed -> reload so the switchover listener re-registers."""
await hass.config_entries.async_reload(entry.entry_id)
def _register_switchover_listener(
hass: HomeAssistant, entry: ConfigEntry, coordinator: WTWCoordinator
) -> None:
opts = {**default_options(), **(entry.options or {})}
when = parse_switchover(opts["switchover_time"])
async def _fire(_now) -> None:
await coordinator.async_request_refresh()
unsub = async_track_time_change(
hass, _fire, hour=when.hour, minute=when.minute, second=when.second
)
entry.async_on_unload(unsub)
def _register_entity_listener(
hass: HomeAssistant, entry: ConfigEntry, coordinator: WTWCoordinator
) -> None:
entity_id = entry.data[CONF_WEATHER_ENTITY]
@callback
def _changed(event) -> None:
new_state = event.data.get("new_state")
# Refresh when the entity becomes available or is removed (so the
# coordinator updates its state); skip only transient unavailable/unknown.
if new_state is None or new_state.state not in ("unavailable", "unknown"):
entry.async_create_task(hass, coordinator.async_request_refresh())
unsub = async_track_state_change_event(hass, [entity_id], _changed)
entry.async_on_unload(unsub)

View file

@ -0,0 +1,95 @@
"""Config flow: weather entity selection with an immediate test fetch (Story 1.8).
FR-1.2: the flow verifies the chosen ``weather.*`` entity right away and maps the
outcome to four distinct, translated errors no weather entity / no forecast /
forecast does not reach the target date / temporarily unavailable. Every message
names a cause and points at the docs (via the translated strings).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import selector
from homeassistant.util import dt as dt_util
from .const import (
CONF_WEATHER_ENTITY,
CONFIG_MINOR_VERSION,
CONFIG_VERSION,
CONFIG_FLOW_TEST_TIMEOUT_S,
DOMAIN,
default_options,
)
from .logic import pipeline
from .logic.schedule import target_date_for
from .weather.errors import ForecastUnsupported
from .weather.ha_entity import HAEntityProvider
_LOGGER = logging.getLogger(__name__)
CONF_OFFSET = "cold_sensitivity_offset"
_UNAVAILABLE = ("unavailable", "unknown")
class WhatToWearConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial setup."""
VERSION = CONFIG_VERSION
MINOR_VERSION = CONFIG_MINOR_VERSION
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
entity_id = user_input[CONF_WEATHER_ENTITY]
error = await self._test_entity(entity_id)
if error is None:
offset = int(user_input.get(CONF_OFFSET, 0))
return self.async_create_entry(
title="What to Wear",
data={CONF_WEATHER_ENTITY: entity_id},
options={"cold_sensitivity_offset": offset},
)
errors["base"] = error
schema = vol.Schema(
{
vol.Required(CONF_WEATHER_ENTITY): selector.EntitySelector(
selector.EntitySelectorConfig(domain="weather")
),
vol.Optional(CONF_OFFSET, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(min=-2, max=2, step=1, mode=selector.NumberSelectorMode.BOX)
),
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def _test_entity(self, entity_id: str) -> str | None:
"""Return an error key, or None if the entity yields a usable forecast."""
state = self.hass.states.get(entity_id)
if state is None:
return "no_weather_entity"
if state.state in _UNAVAILABLE:
return "temporary"
provider = HAEntityProvider(self.hass, entity_id)
try:
async with asyncio.timeout(CONFIG_FLOW_TEST_TIMEOUT_S):
raw = await provider.get_raw_forecast()
except ForecastUnsupported:
return "no_forecast"
except Exception as err: # noqa: BLE001 - any fetch failure is "temporary"
# CancelledError is a BaseException and is intentionally not caught.
_LOGGER.debug("Test fetch for %s failed: %s", entity_id, err)
return "temporary"
target, _ = target_date_for(dt_util.now(), default_options()["switchover_time"])
if not pipeline.covers_target(raw, target):
return "forecast_too_short"
return None

View file

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "What to Wear",
"description": "Wähle die Wetter-Entität, deren Prognose die Empfehlung bestimmen soll. Sie wird sofort geprüft.",
"data": {
"weather_entity_id": "Wetter-Entität",
"cold_sensitivity_offset": "Kälteempfinden (-2 = mir ist schnell warm, +2 = ich friere leicht)"
}
}
},
"error": {
"no_weather_entity": "Diese Wetter-Entität wurde nicht gefunden. Wähle eine vorhandene weather.*-Entität.",
"no_forecast": "Diese Entität liefert keine Tages- oder Stundenprognose. Wähle eine Wetter-Integration mit Prognose (siehe Doku).",
"forecast_too_short": "Die Prognose reicht nicht bis morgen. Wähle eine Entität mit Mehrtagesprognose.",
"temporary": "Die Wetter-Entität ist vorübergehend nicht verfügbar. Versuche es gleich erneut."
},
"abort": {
"single_instance_allowed": "What to Wear ist bereits eingerichtet."
}
}
}

View file

@ -0,0 +1,23 @@
{
"config": {
"step": {
"user": {
"title": "What to Wear",
"description": "Choose the weather entity whose forecast should drive the recommendation. It is verified right away.",
"data": {
"weather_entity_id": "Weather entity",
"cold_sensitivity_offset": "Cold sensitivity (-2 = I run warm, +2 = I get cold easily)"
}
}
},
"error": {
"no_weather_entity": "That weather entity was not found. Pick an existing weather.* entity.",
"no_forecast": "This entity provides no daily or hourly forecast. Choose a weather integration that supports forecasts (see the docs).",
"forecast_too_short": "The forecast does not reach tomorrow. Choose an entity with a multi-day forecast.",
"temporary": "The weather entity is temporarily unavailable. Try again in a moment."
},
"abort": {
"single_instance_allowed": "What to Wear is already configured."
}
}
}

5
requirements-test.txt Normal file
View file

@ -0,0 +1,5 @@
# Dev/test dependencies for the min-HA target (HA 2025.3.4, Python 3.13).
# aiohttp==3.11.13 is yanked but pinned by HA 2025.3.4 -> explicit pin required.
pytest-homeassistant-custom-component==0.13.225
aiohttp==3.11.13
home-assistant-frontend==20250306.0

View file

@ -8,3 +8,9 @@ import pytest
def _enable_custom_integrations(enable_custom_integrations):
"""Let Home Assistant load the ``what_to_wear`` custom integration in tests."""
yield
@pytest.fixture(autouse=True)
def _mock_storage(hass_storage):
"""Use in-memory storage so delayed Store writes leave no lingering timers."""
yield

142
tests/test_config_flow.py Normal file
View file

@ -0,0 +1,142 @@
"""Story 1.8 — config flow (test fetch, 4 errors) and entry lifecycle."""
from __future__ import annotations
from datetime import timedelta
import pytest
from homeassistant.components.weather import WeatherEntityFeature
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.what_to_wear.const import CONF_WEATHER_ENTITY, DOMAIN
ENTITY = "weather.home"
def _set_weather(hass, features=WeatherEntityFeature.FORECAST_DAILY, state="cloudy") -> None:
hass.states.async_set(
ENTITY,
state,
{
"temperature_unit": "°C",
"wind_speed_unit": "km/h",
"precipitation_unit": "mm",
"supported_features": features,
},
)
def _register_forecast(hass, entries=None, raises=False) -> None:
async def handler(call) -> ServiceResponse:
if raises:
raise HomeAssistantError("unavailable")
base = dt_util.now()
default = [
{"datetime": (base + timedelta(days=off)).replace(hour=12), "temperature": 10.0,
"templow": 5.0}
for off in range(-1, 4)
]
return {call.data["entity_id"]: {"forecast": entries if entries is not None else default}}
hass.services.async_register(
"weather", "get_forecasts", handler, supports_response=SupportsResponse.ONLY
)
async def test_flow_success_creates_entry(hass: HomeAssistant) -> None:
_set_weather(hass)
_register_forecast(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert result["type"] == FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: ENTITY, "cold_sensitivity_offset": 1}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["data"] == {CONF_WEATHER_ENTITY: ENTITY}
assert result2["options"]["cold_sensitivity_offset"] == 1
async def test_flow_error_no_weather_entity(hass: HomeAssistant) -> None:
_register_forecast(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: "weather.ghost"}
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "no_weather_entity"}
async def test_flow_error_no_forecast(hass: HomeAssistant) -> None:
_set_weather(hass, features=WeatherEntityFeature.FORECAST_TWICE_DAILY)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: ENTITY}
)
assert result2["errors"] == {"base": "no_forecast"}
async def test_flow_error_forecast_too_short(hass: HomeAssistant) -> None:
_set_weather(hass)
# forecast only for a far-away day -> does not reach the target date
far = [{"datetime": (dt_util.now() + timedelta(days=20)).replace(hour=12), "temperature": 10.0}]
_register_forecast(hass, entries=far)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: ENTITY}
)
assert result2["errors"] == {"base": "forecast_too_short"}
async def test_flow_error_temporary_when_unavailable(hass: HomeAssistant) -> None:
_set_weather(hass, state="unavailable")
_register_forecast(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: ENTITY}
)
assert result2["errors"] == {"base": "temporary"}
async def test_flow_error_temporary_on_service_error(hass: HomeAssistant) -> None:
# any fetch failure (not just HomeAssistantError) maps to temporary (F1).
_set_weather(hass)
_register_forecast(hass, raises=True)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_WEATHER_ENTITY: ENTITY}
)
assert result2["errors"] == {"base": "temporary"}
async def test_setup_unload_setup_no_leak(hass: HomeAssistant) -> None:
_set_weather(hass)
_register_forecast(hass)
entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEATHER_ENTITY: ENTITY}, options={})
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id in hass.data[DOMAIN]
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id not in hass.data.get(DOMAIN, {})
# setting up again must not raise (listeners were cancelled leak-free)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.entry_id in hass.data[DOMAIN]
async def test_single_config_entry(hass: HomeAssistant) -> None:
_set_weather(hass)
_register_forecast(hass)
entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEATHER_ENTITY: ENTITY})
entry.add_to_hass(hass)
# a second user flow must abort (single_config_entry manifest flag)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
assert result["type"] == FlowResultType.ABORT