what_to_wear/tests/test_coordinator.py
Nora e11e320093 feat(1.7): Coordinator (einziger Mutator) + reine Pipeline/Zieldatum
Story 1.7 (TDD, phcc, Suite gruen 97/97):
- logic/pipeline.py: reine End-to-End-Pipeline (normalize->rules->matcher->assemble)
  inkl. no-coverage-Fehlerpfad; logic/schedule.py: Zieldatum/Umschaltzeit (halboffen).
- coordinator.py: DataUpdateCoordinator, asyncio.Lock, timeout(10), einheitlicher
  Fehler-Contract (UpdateFailed mit Vorgaenger / fehler_prognose sonst), changed via
  helpers.storage.Store, last_success_utc, defensiver Item-Aufbau aus Subentries.
- luna-pro-Story-Review: 5/6 Findings uebernommen, 1 verworfen (.time()-naiv-Evidenz).

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

183 lines
6.5 KiB
Python

"""Story 1.7 — the coordinator (single mutator), phcc integration."""
from __future__ import annotations
import time as _time
from datetime import timedelta
import pytest
from homeassistant.config_entries import ConfigSubentryData
from homeassistant.core import HomeAssistant
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
from custom_components.what_to_wear.coordinator import WTWCoordinator, build_items
from custom_components.what_to_wear.logic.model import RawForecast
class FakeProvider:
def __init__(self, raw: RawForecast | None = None, error: Exception | None = None):
self.raw = raw
self.error = error
self.calls = 0
async def get_raw_forecast(self) -> RawForecast:
self.calls += 1
if self.error is not None:
raise self.error
return self.raw
def _wide_raw(temp: float = 5.0) -> RawForecast:
base = dt_util.now()
daily = tuple(
{
"datetime": (base + timedelta(days=off)).replace(hour=12, minute=0),
"temperature": temp + 3,
"templow": temp,
"precipitation_probability": 50,
}
for off in range(-1, 4)
)
hourly = tuple(
{"datetime": (base + timedelta(days=off)).replace(hour=7, minute=0), "temperature": temp}
for off in range(-1, 4)
)
return RawForecast(
time_zone=str(dt_util.DEFAULT_TIME_ZONE),
units={"temperature": "°C", "wind_speed": "km/h", "precipitation": "mm"},
daily=daily,
hourly=hourly,
)
def _entry(hass: HomeAssistant, subentries=()) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_WEATHER_ENTITY: "weather.home"},
subentries_data=list(subentries),
)
entry.add_to_hass(hass)
return entry
def _item_sub(name, category, warmth, **data) -> ConfigSubentryData:
return ConfigSubentryData(
data={"name": name, "category": category, "warmth": warmth, **data},
subentry_type="item",
title=name,
unique_id=None,
)
async def test_ok_recommendation_with_items(hass: HomeAssistant) -> None:
subs = [
_item_sub("Shirt", "top", 3),
_item_sub("Jeans", "bottom", 3),
_item_sub("Boots", "shoes", 3),
]
entry = _entry(hass, subs)
coord = WTWCoordinator(hass, entry, FakeProvider(_wide_raw(5.0)))
await coord.async_refresh()
assert coord.last_update_success is True
assert coord.data.status == "ok"
assert {ci.item.name for ci in coord.data.items} >= {"Shirt", "Jeans", "Boots"}
assert coord.last_success_utc is not None
async def test_error_without_prior_data_returns_fehler(hass: HomeAssistant) -> None:
entry = _entry(hass)
coord = WTWCoordinator(hass, entry, FakeProvider(error=HomeAssistantError("boom")))
await coord.async_refresh()
# Sensor gets a fehler recommendation, never None (AD-2/AD-7).
assert coord.data is not None
assert coord.data.status == "fehler_prognose"
async def test_error_with_prior_data_retains_last(hass: HomeAssistant) -> None:
entry = _entry(hass)
provider = FakeProvider(_wide_raw(5.0))
coord = WTWCoordinator(hass, entry, provider)
await coord.async_refresh()
good = coord.data
assert good.status == "ok"
# now fail
provider.error = HomeAssistantError("later failure")
await coord.async_refresh()
assert coord.last_update_success is False
assert coord.data is good # last good recommendation retained
async def test_changed_flag_transitions(hass: HomeAssistant) -> None:
entry = _entry(hass, [_item_sub("Shirt", "top", 3), _item_sub("Jeans", "bottom", 3),
_item_sub("Boots", "shoes", 3)])
provider = FakeProvider(_wide_raw(5.0))
coord = WTWCoordinator(hass, entry, provider)
await coord.async_refresh()
assert coord.data.changed is True # first compute: no prior signature -> changed (AD-19)
await coord.async_refresh()
assert coord.data.changed is False # identical -> not changed
provider.raw = _wide_raw(-2.0) # colder -> different outfit/warmth
await coord.async_refresh()
assert coord.data.changed is True
async def test_no_coverage_with_prior_good_retains_last(hass: HomeAssistant) -> None:
entry = _entry(hass)
provider = FakeProvider(_wide_raw(5.0))
coord = WTWCoordinator(hass, entry, provider)
await coord.async_refresh()
good = coord.data
assert good.status == "ok"
# forecast now only covers a far-away day -> no coverage for target
provider.raw = RawForecast(
time_zone=str(dt_util.DEFAULT_TIME_ZONE),
units={"temperature": "°C"},
daily=({"datetime": (dt_util.now() + timedelta(days=20)).replace(hour=12),
"temperature": 15.0},),
hourly=(),
)
await coord.async_refresh()
assert coord.last_update_success is False
assert coord.data is good # last good recommendation retained (AD-2/AD-7)
async def test_no_coverage_without_prior_delivers_fehler(hass: HomeAssistant) -> None:
entry = _entry(hass)
far = RawForecast(
time_zone=str(dt_util.DEFAULT_TIME_ZONE),
units={"temperature": "°C"},
daily=({"datetime": (dt_util.now() + timedelta(days=20)).replace(hour=12),
"temperature": 15.0},),
hourly=(),
)
coord = WTWCoordinator(hass, entry, FakeProvider(far))
await coord.async_refresh()
assert coord.data is not None and coord.data.status == "fehler_prognose"
def test_build_items_bool_coercion(hass: HomeAssistant) -> None:
entry = _entry(hass, [_item_sub("Coat", "jacket", 3, waterproof="false", active="0")])
item = build_items(entry)[0]
assert item.waterproof is False # "false" string must not become True
assert item.active is False # "0" -> False
async def test_refresh_under_5s(hass: HomeAssistant) -> None:
entry = _entry(hass)
coord = WTWCoordinator(hass, entry, FakeProvider(_wide_raw(5.0)))
start = _time.monotonic()
await coord.async_refresh()
assert _time.monotonic() - start < 5.0 # NFR-7 (without LLM)
def test_build_items_skips_unknown_category_and_blank(hass: HomeAssistant) -> None:
entry = _entry(hass, [
_item_sub("Good", "top", 2),
_item_sub("BadCat", "wizard-hat", 2),
_item_sub("", "bottom", 2),
])
items = build_items(entry)
names = {i.name for i in items}
assert names == {"Good"}