Story 3.1 (TDD, phcc, Suite gruen 135/135): - config_flow.py: WhatToWearOptionsFlow (Property-Muster), _build_options (Validierung+Normalisierung, Bandgrenzen strikt monoton, HH:MM:SS kanonisch, LLM-Block bewahrt), atomares async_update_entry(data+options)+abort -> ein Reload. - async_test_entity als Modulfunktion mit switchover-Param (Zieldatum korrekt). - coordinator/__init__: data-Snapshot -> Reload auch bei Wetter-Entitaet-Wechsel. - translations options/abort/error; async_get_options_flow. - luna-pro-Story-Review: 7/7 Findings uebernommen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
4.8 KiB
Python
116 lines
4.8 KiB
Python
"""Story 3.1 — options flow (thresholds, offset, switchover, weather entity)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
from homeassistant.components.weather import WeatherEntityFeature
|
|
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
|
from homeassistant.data_entry_flow import FlowResultType
|
|
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"
|
|
ENTITY2 = "weather.station"
|
|
|
|
|
|
def _set_weather(hass, entity_id=ENTITY) -> None:
|
|
hass.states.async_set(
|
|
entity_id, "cloudy",
|
|
{"temperature_unit": "°C", "wind_speed_unit": "km/h", "precipitation_unit": "mm",
|
|
"supported_features": WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY},
|
|
)
|
|
|
|
|
|
def _register_forecast(hass) -> None:
|
|
async def handler(call) -> ServiceResponse:
|
|
base = dt_util.now()
|
|
entries = [{"datetime": (base + timedelta(days=o)).replace(hour=12), "temperature": 8.0,
|
|
"templow": 5.0} for o in range(-1, 4)]
|
|
return {call.data["entity_id"]: {"forecast": entries}}
|
|
|
|
hass.services.async_register("weather", "get_forecasts", handler,
|
|
supports_response=SupportsResponse.ONLY)
|
|
|
|
|
|
async def _setup(hass) -> MockConfigEntry:
|
|
_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()
|
|
return entry
|
|
|
|
|
|
def _full_input(**over):
|
|
base = {
|
|
CONF_WEATHER_ENTITY: ENTITY,
|
|
"cold_sensitivity_offset": 0,
|
|
"switchover_time": "10:00:00",
|
|
"warmth_band_0": 0.0, "warmth_band_1": 8.0, "warmth_band_2": 15.0, "warmth_band_3": 22.0,
|
|
"heat_threshold": 28.0, "rain_prob_should": 40, "rain_prob_must": 70,
|
|
"rain_amount_should": 1.0, "rain_amount_must": 5.0, "gust_should": 40, "wind_proxy_should": 30,
|
|
"uv_should": 6,
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
async def test_options_roundtrip(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
assert result["type"] == FlowResultType.FORM
|
|
result2 = await hass.config_entries.options.async_configure(
|
|
result["flow_id"], _full_input(cold_sensitivity_offset=2, switchover_time="09:30:00")
|
|
)
|
|
assert result2["type"] == FlowResultType.ABORT
|
|
await hass.async_block_till_done()
|
|
assert entry.options["cold_sensitivity_offset"] == 2
|
|
assert entry.options["switchover_time"] == "09:30:00"
|
|
assert entry.options["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0]
|
|
|
|
|
|
async def test_options_band_monotone_validation(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
result2 = await hass.config_entries.options.async_configure(
|
|
result["flow_id"], _full_input(warmth_band_1=20.0) # 0,20,15,22 -> not monotone
|
|
)
|
|
assert result2["type"] == FlowResultType.FORM
|
|
assert result2["errors"] == {"base": "band_not_monotone"}
|
|
|
|
|
|
async def test_options_change_weather_entity(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
_set_weather(hass, ENTITY2) # a second valid weather entity
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
result2 = await hass.config_entries.options.async_configure(
|
|
result["flow_id"], _full_input(**{CONF_WEATHER_ENTITY: ENTITY2})
|
|
)
|
|
assert result2["type"] == FlowResultType.ABORT
|
|
await hass.async_block_till_done()
|
|
assert entry.data[CONF_WEATHER_ENTITY] == ENTITY2 # written to entry.data
|
|
|
|
|
|
async def test_options_change_to_bad_entity_errors(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
result2 = await hass.config_entries.options.async_configure(
|
|
result["flow_id"], _full_input(**{CONF_WEATHER_ENTITY: "weather.ghost"})
|
|
)
|
|
assert result2["type"] == FlowResultType.FORM
|
|
assert result2["errors"] == {"base": "no_weather_entity"}
|
|
|
|
|
|
async def test_options_wired_into_rules(hass: HomeAssistant) -> None:
|
|
# A stricter rain threshold flows through to the coordinator after reload.
|
|
entry = await _setup(hass)
|
|
result = await hass.config_entries.options.async_init(entry.entry_id)
|
|
await hass.config_entries.options.async_configure(
|
|
result["flow_id"], _full_input(rain_prob_should=95)
|
|
)
|
|
await hass.async_block_till_done()
|
|
coordinator = hass.data[DOMAIN][entry.entry_id]
|
|
assert coordinator._options()["rain_prob_should"] == 95
|