From 3336fc0c2c4fdfd585532ecd95862e881508cd7a Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 13 Jul 2026 15:13:25 +0000 Subject: [PATCH] feat(3.2): Schema-Versionierung, Migration & Options-Robustheit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- LEDGER.md | 8 ++ custom_components/what_to_wear/__init__.py | 15 ++- custom_components/what_to_wear/const.py | 80 +++++++++++++ custom_components/what_to_wear/coordinator.py | 7 +- tests/test_migration.py | 105 ++++++++++++++++++ 5 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 tests/test_migration.py diff --git a/LEDGER.md b/LEDGER.md index fbaff56..bb6762a 100644 --- a/LEDGER.md +++ b/LEDGER.md @@ -314,3 +314,11 @@ Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale. 7 Findings, alle 7 übernommen (F1+F7 atomares async_update_entry+abort → ein Reload; F2 switchover an Test-Abruf; F3 HH:MM:SS-Normalisierung; F4 try um Konvertierung; F5 alle Schwellen typ-normalisiert; F6 Options aus current-Kopie → LLM-Block bewahrt). +- **3.2 Schema-Versionierung & Migration** ✅ — 141 Tests grün. `__init__.py` (async_migrate_entry + v1→v1 no-op), `const.py` (sanitized_options: fehlend→Default, typfalsch→Default+Warn, unbekannt→ + erhalten, nie Abbruch; Bereichs-/NaN-/HH:MM:SS-Prüfung), `coordinator.py` (robustes _options) + + `tests/test_migration.py`. **luna-pro-Review:** 5 Findings, 4 übernommen (F1 Switchover-Listener + nutzt Sanitizer; F2 Bereichs-/isfinite-Prüfung; F3 HH:MM:SS-Validierung; F4 non-Mapping-Guard), + 1 verworfen mit Evidenz (F5 VERSION/MINOR_VERSION sind in config_flow.py gesetzt). **Eigener Fund + beim Verifizieren:** F4-Guard `isinstance(dict)` verwarf `MappingProxyType` (entry.options) → auf + `Mapping` korrigiert (Test fing es). diff --git a/custom_components/what_to_wear/__init__.py b/custom_components/what_to_wear/__init__.py index 70eae18..c07c770 100644 --- a/custom_components/what_to_wear/__init__.py +++ b/custom_components/what_to_wear/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.event import ( async_track_time_change, ) -from .const import CONF_WEATHER_ENTITY, DOMAIN, default_options +from .const import CONF_WEATHER_ENTITY, DOMAIN, sanitized_options from .coordinator import WTWCoordinator from .frontend import async_setup_card, async_unregister_card_resource from .logic.schedule import parse_switchover @@ -62,6 +62,17 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await async_unregister_card_resource(hass) +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate a config entry to the current schema version (NFR-10/AD-8). + + v1.1 is the initial schema, so this is a no-op; future schema changes bump + ``VERSION``/``MINOR_VERSION`` and transform data/subentries here (subentries + are migrated via ``async_update_subentry`` over the entry version). + """ + _LOGGER.debug("Migrating entry %s from v%s.%s", entry.entry_id, entry.version, entry.minor_version) + return True + + async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Route entry updates: options change -> reload; subentry change -> refresh (AD-7).""" coordinator: WTWCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id) @@ -82,7 +93,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non def _register_switchover_listener( hass: HomeAssistant, entry: ConfigEntry, coordinator: WTWCoordinator ) -> None: - opts = {**default_options(), **(entry.options or {})} + opts = sanitized_options(entry.options or {}, warn=_LOGGER.warning) when = parse_switchover(opts["switchover_time"]) async def _fire(_now) -> None: diff --git a/custom_components/what_to_wear/const.py b/custom_components/what_to_wear/const.py index 71ad1a6..c4c8f6e 100644 --- a/custom_components/what_to_wear/const.py +++ b/custom_components/what_to_wear/const.py @@ -7,6 +7,9 @@ story wires against these constants rather than rebuilding the schema. from __future__ import annotations import copy +import math +import re +from collections.abc import Mapping from typing import Any, Final DOMAIN: Final = "what_to_wear" @@ -60,6 +63,83 @@ def default_options() -> dict[str, Any]: return copy.deepcopy(OPTIONS_DEFAULTS) +_TIME_RE = re.compile(r"^\d{2}:\d{2}:\d{2}$") + +# Numeric ranges for the threshold options (finite + within range). +_RANGES: Final[dict[str, tuple[float, float]]] = { + "cold_sensitivity_offset": (-2, 2), + "heat_threshold": (-60.0, 60.0), + "rain_prob_should": (0, 100), + "rain_prob_must": (0, 100), + "rain_amount_should": (0.0, 500.0), + "rain_amount_must": (0.0, 500.0), + "gust_should": (0.0, 250.0), + "wind_proxy_should": (0.0, 250.0), + "uv_should": (0, 16), +} + + +def _finite_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def _valid_time(value: Any) -> bool: + if not isinstance(value, str) or not _TIME_RE.match(value): + return False + try: + from datetime import time as _t + + _t.fromisoformat(value) + except (ValueError, TypeError): + return False + return True + + +def _valid(key: str, value: Any) -> bool: + """Type/shape/range check for one option key (AD-8 robustness contract).""" + if key == "warmth_band_limits": + return ( + isinstance(value, (list, tuple)) + and len(value) == 4 + and all(_finite_number(v) and -60.0 <= v <= 60.0 for v in value) + and all(a < b for a, b in zip(value, value[1:])) + ) + if key == "llm_enabled": + return isinstance(value, bool) + if key == "llm_provider": + return value in ("openai", "anthropic") + if key in ("llm_api_key", "llm_model"): + return isinstance(value, str) + if key == "switchover_time": + return _valid_time(value) + # remaining keys are numeric thresholds with a finite range + if not _finite_number(value): + return False + lo, hi = _RANGES.get(key, (-math.inf, math.inf)) + return lo <= value <= hi + + +def sanitized_options(stored: dict[str, Any], warn=None) -> dict[str, Any]: + """Merge stored options over defaults, defaulting any missing/ill-typed value. + + Missing keys fall back to the default; type-wrong values fall back to the + default (and are reported via ``warn``); unknown stored keys are ignored by + the logic but remain untouched in storage. Never raises (AD-8). + """ + opts = default_options() + if not isinstance(stored, Mapping): + return opts # corrupt/non-mapping storage -> all defaults (never raises) + for key, default in opts.items(): + if key not in stored: + continue + value = stored[key] + if _valid(key, value): + opts[key] = value + elif warn is not None: + warn("Option %s has an unexpected value (%r); using default", key, value) + return opts + + # Diagnostics redaction — exact stored key names (AD-5). TO_REDACT: Final = {"llm_api_key", CONF_WEATHER_ENTITY} diff --git a/custom_components/what_to_wear/coordinator.py b/custom_components/what_to_wear/coordinator.py index ac3c8b7..ebd4f5d 100644 --- a/custom_components/what_to_wear/coordinator.py +++ b/custom_components/what_to_wear/coordinator.py @@ -29,7 +29,7 @@ from .const import ( FORECAST_TIMEOUT_S, SUBENTRY_TYPE_ITEM, UPDATE_INTERVAL_HOURS, - default_options, + sanitized_options, ) from .logic import pipeline from .logic.assemble import assemble @@ -130,9 +130,8 @@ class WTWCoordinator(DataUpdateCoordinator[Recommendation]): self.data_snapshot: dict = dict(entry.data or {}) def _options(self) -> dict: - opts = default_options() - opts.update(self._entry.options or {}) - return opts + # Robust read: missing -> default, type-wrong -> default + warn (AD-8). + return sanitized_options(self._entry.options or {}, warn=_LOGGER.warning) def _fehler(self, language: str, target_date: str, created_at: str, source, label: str) -> Recommendation: return assemble( diff --git a/tests/test_migration.py b/tests/test_migration.py new file mode 100644 index 0000000..efd1f55 --- /dev/null +++ b/tests/test_migration.py @@ -0,0 +1,105 @@ +"""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