what_to_wear/tests/test_migration.py
Nora 3336fc0c2c feat(3.2): Schema-Versionierung, Migration & Options-Robustheit
Story 3.2 (TDD, phcc, Suite gruen 141/141):
- __init__.py: async_migrate_entry (v1->v1 no-op, NFR-10/AD-8).
- const.py: sanitized_options — fehlend->Default, typfalsch->Default+Warn,
  unbekannt->erhalten, Bereichs-/isfinite-/HH:MM:SS-Pruefung, non-Mapping-Guard,
  nie Setup-Abbruch (AD-8).
- coordinator._options + Switchover-Listener nutzen den Sanitizer.
- luna-pro-Story-Review: 4/5 Findings uebernommen, 1 verworfen (Version-Evidenz).
  Eigener Fund: Mapping-vs-dict-Guard (entry.options=MappingProxyType) korrigiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:13:25 +00:00

105 lines
4.1 KiB
Python

"""Story 3.2 — schema versioning, migration no-op, options robustness (AD-8)."""
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.components.weather import WeatherEntityFeature
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.what_to_wear import const
from custom_components.what_to_wear.const import (
CONF_WEATHER_ENTITY,
CONFIG_MINOR_VERSION,
CONFIG_VERSION,
DOMAIN,
)
ENTITY = "weather.home"
def _set_weather(hass) -> None:
hass.states.async_set(
ENTITY, "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)
# --- pure sanitizer tests (AD-8 robustness contract) ------------------------
def test_sanitized_missing_uses_default() -> None:
opts = const.sanitized_options({})
assert opts == const.default_options()
def test_sanitized_type_wrong_falls_back_and_warns(caplog) -> None:
warnings = []
bad = {
"warmth_band_limits": "oops", # not a list
"cold_sensitivity_offset": "x", # not numeric
"switchover_time": 123, # not a string
"llm_enabled": "yes", # not a bool
"rain_prob_should": 55, # valid
}
opts = const.sanitized_options(bad, warn=lambda *a: warnings.append(a))
assert opts["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0]
assert opts["cold_sensitivity_offset"] == 0
assert opts["switchover_time"] == "10:00:00"
assert opts["llm_enabled"] is False
assert opts["rain_prob_should"] == 55 # the valid one is kept
assert len(warnings) == 4 # one per bad key
def test_sanitized_non_monotone_bands_rejected() -> None:
opts = const.sanitized_options({"warmth_band_limits": [0, 20, 15, 22]})
assert opts["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0] # default
def test_sanitized_unknown_key_ignored() -> None:
opts = const.sanitized_options({"unknown_future_key": 42})
assert "unknown_future_key" not in opts # logic ignores it (storage keeps it)
# --- migration + robust load in a real entry --------------------------------
async def test_entry_carries_version(hass: HomeAssistant) -> None:
_set_weather(hass)
_register_forecast(hass)
entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEATHER_ENTITY: ENTITY},
version=CONFIG_VERSION, minor_version=CONFIG_MINOR_VERSION)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == CONFIG_VERSION and entry.minor_version == CONFIG_MINOR_VERSION
async def test_setup_survives_corrupt_options(hass: HomeAssistant) -> None:
_set_weather(hass)
_register_forecast(hass)
entry = MockConfigEntry(
domain=DOMAIN, data={CONF_WEATHER_ENTITY: ENTITY},
options={"warmth_band_limits": "corrupt", "rain_prob_should": "NaN", "future_key": 1},
)
entry.add_to_hass(hass)
# setup must not crash despite corrupt options (AD-8)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
o = coordinator._options()
assert o["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0] # defaulted
assert o["rain_prob_should"] == 40 # defaulted
assert coordinator.data is not None # still produced a recommendation