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:
parent
e11e320093
commit
95a077f0c1
8 changed files with 385 additions and 4 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
95
custom_components/what_to_wear/config_flow.py
Normal file
95
custom_components/what_to_wear/config_flow.py
Normal 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
|
||||
23
custom_components/what_to_wear/translations/de.json
Normal file
23
custom_components/what_to_wear/translations/de.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
23
custom_components/what_to_wear/translations/en.json
Normal file
23
custom_components/what_to_wear/translations/en.json
Normal 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
5
requirements-test.txt
Normal 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
|
||||
|
|
@ -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
142
tests/test_config_flow.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue