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>
142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
"""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
|