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>
183 lines
6.5 KiB
Python
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"}
|