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>
This commit is contained in:
parent
ff35d62679
commit
3336fc0c2c
5 changed files with 209 additions and 6 deletions
|
|
@ -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
|
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;
|
Test-Abruf; F3 HH:MM:SS-Normalisierung; F4 try um Konvertierung; F5 alle Schwellen typ-normalisiert;
|
||||||
F6 Options aus current-Kopie → LLM-Block bewahrt).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from homeassistant.helpers.event import (
|
||||||
async_track_time_change,
|
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 .coordinator import WTWCoordinator
|
||||||
from .frontend import async_setup_card, async_unregister_card_resource
|
from .frontend import async_setup_card, async_unregister_card_resource
|
||||||
from .logic.schedule import parse_switchover
|
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)
|
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:
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Route entry updates: options change -> reload; subentry change -> refresh (AD-7)."""
|
"""Route entry updates: options change -> reload; subentry change -> refresh (AD-7)."""
|
||||||
coordinator: WTWCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
|
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(
|
def _register_switchover_listener(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, coordinator: WTWCoordinator
|
hass: HomeAssistant, entry: ConfigEntry, coordinator: WTWCoordinator
|
||||||
) -> None:
|
) -> None:
|
||||||
opts = {**default_options(), **(entry.options or {})}
|
opts = sanitized_options(entry.options or {}, warn=_LOGGER.warning)
|
||||||
when = parse_switchover(opts["switchover_time"])
|
when = parse_switchover(opts["switchover_time"])
|
||||||
|
|
||||||
async def _fire(_now) -> None:
|
async def _fire(_now) -> None:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ story wires against these constants rather than rebuilding the schema.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
DOMAIN: Final = "what_to_wear"
|
DOMAIN: Final = "what_to_wear"
|
||||||
|
|
@ -60,6 +63,83 @@ def default_options() -> dict[str, Any]:
|
||||||
return copy.deepcopy(OPTIONS_DEFAULTS)
|
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).
|
# Diagnostics redaction — exact stored key names (AD-5).
|
||||||
TO_REDACT: Final = {"llm_api_key", CONF_WEATHER_ENTITY}
|
TO_REDACT: Final = {"llm_api_key", CONF_WEATHER_ENTITY}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ from .const import (
|
||||||
FORECAST_TIMEOUT_S,
|
FORECAST_TIMEOUT_S,
|
||||||
SUBENTRY_TYPE_ITEM,
|
SUBENTRY_TYPE_ITEM,
|
||||||
UPDATE_INTERVAL_HOURS,
|
UPDATE_INTERVAL_HOURS,
|
||||||
default_options,
|
sanitized_options,
|
||||||
)
|
)
|
||||||
from .logic import pipeline
|
from .logic import pipeline
|
||||||
from .logic.assemble import assemble
|
from .logic.assemble import assemble
|
||||||
|
|
@ -130,9 +130,8 @@ class WTWCoordinator(DataUpdateCoordinator[Recommendation]):
|
||||||
self.data_snapshot: dict = dict(entry.data or {})
|
self.data_snapshot: dict = dict(entry.data or {})
|
||||||
|
|
||||||
def _options(self) -> dict:
|
def _options(self) -> dict:
|
||||||
opts = default_options()
|
# Robust read: missing -> default, type-wrong -> default + warn (AD-8).
|
||||||
opts.update(self._entry.options or {})
|
return sanitized_options(self._entry.options or {}, warn=_LOGGER.warning)
|
||||||
return opts
|
|
||||||
|
|
||||||
def _fehler(self, language: str, target_date: str, created_at: str, source, label: str) -> Recommendation:
|
def _fehler(self, language: str, target_date: str, created_at: str, source, label: str) -> Recommendation:
|
||||||
return assemble(
|
return assemble(
|
||||||
|
|
|
||||||
105
tests/test_migration.py
Normal file
105
tests/test_migration.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue