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>
This commit is contained in:
parent
5f5cdac259
commit
e11e320093
9 changed files with 616 additions and 2 deletions
77
tests/logic/test_pipeline.py
Normal file
77
tests/logic/test_pipeline.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Story 1.7 (pure core) — end-to-end pipeline (no HA harness)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from custom_components.what_to_wear import const
|
||||
from custom_components.what_to_wear.logic import pipeline
|
||||
from custom_components.what_to_wear.logic.model import Category, Item, RawForecast
|
||||
|
||||
TARGET = date(2026, 7, 14)
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def _raw(daily=(), hourly=()) -> RawForecast:
|
||||
return RawForecast(
|
||||
time_zone="Europe/Berlin",
|
||||
units={"temperature": "°C", "wind_speed": "km/h", "precipitation": "mm"},
|
||||
daily=daily,
|
||||
hourly=hourly,
|
||||
)
|
||||
|
||||
|
||||
def _wardrobe():
|
||||
return [
|
||||
Item("t", "Thermoshirt", Category.TOP, 3),
|
||||
Item("w", "Wollpullover", Category.SWEATER, 4),
|
||||
Item("r", "Regenjacke", Category.JACKET, 2, waterproof=True, windproof=True),
|
||||
Item("j", "Jeans", Category.BOTTOM, 3),
|
||||
Item("b", "Stiefel", Category.SHOES, 4, waterproof=True),
|
||||
Item("m", "Mütze", Category.HEAD, 4),
|
||||
Item("s", "Schal", Category.NECK, 3),
|
||||
]
|
||||
|
||||
|
||||
def test_full_pipeline_produces_ok_recommendation() -> None:
|
||||
daily = ({"datetime": datetime(2026, 7, 14, 10, tzinfo=UTC), "temperature": 1.0,
|
||||
"templow": -2.0, "precipitation_probability": 70, "condition": "rainy"},)
|
||||
hourly = ({"datetime": datetime(2026, 7, 14, 5, tzinfo=UTC), "temperature": -1.0},) # 07:00 local
|
||||
rec = pipeline.run(
|
||||
_raw(daily, hourly), TARGET, _wardrobe(), const.default_options(), "de",
|
||||
created_at="2026-07-13T20:00:00+02:00", forecast_fetched_at="2026-07-13T19:55:00+02:00",
|
||||
source="weather.home",
|
||||
)
|
||||
assert rec.status == "ok"
|
||||
names = {ci.item.name for ci in rec.items}
|
||||
assert {"Thermoshirt", "Regenjacke", "Jeans", "Stiefel"} <= names
|
||||
assert rec.target_date == "2026-07-14"
|
||||
assert rec.signature
|
||||
|
||||
|
||||
def test_pipeline_no_coverage_is_fehler() -> None:
|
||||
# forecast only for a different day -> fehler_prognose (FR-3.4).
|
||||
other = ({"datetime": datetime(2026, 7, 20, 10, tzinfo=UTC), "temperature": 15.0},)
|
||||
rec = pipeline.run(
|
||||
_raw(other), TARGET, _wardrobe(), const.default_options(), "de",
|
||||
created_at="2026-07-13T20:00:00+02:00", forecast_fetched_at=None, source=None,
|
||||
)
|
||||
assert rec.status == "fehler_prognose"
|
||||
assert not rec.items
|
||||
assert "Wetterprognose" in rec.short_text
|
||||
|
||||
|
||||
def test_pipeline_empty_wardrobe_still_ok_with_gaps() -> None:
|
||||
daily = ({"datetime": datetime(2026, 7, 14, 10, tzinfo=UTC), "temperature": 5.0, "templow": 3.0},)
|
||||
rec = pipeline.run(
|
||||
_raw(daily), TARGET, [], const.default_options(), "en",
|
||||
created_at="2026-07-13T20:00:00+02:00", forecast_fetched_at=None, source=None,
|
||||
)
|
||||
assert rec.status == "ok" # empty wardrobe is not an error (FR-2.3)
|
||||
assert not rec.items
|
||||
assert rec.gaps # base categories named as gaps
|
||||
|
||||
|
||||
def test_covers_target_predicate() -> None:
|
||||
daily = ({"datetime": datetime(2026, 7, 14, 10, tzinfo=UTC), "temperature": 5.0},)
|
||||
assert pipeline.covers_target(_raw(daily), TARGET)
|
||||
assert not pipeline.covers_target(_raw(), TARGET)
|
||||
42
tests/logic/test_schedule.py
Normal file
42
tests/logic/test_schedule.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Story 1.7 core — target-date / switchover (no HA harness, AD-3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from custom_components.what_to_wear.logic.schedule import parse_switchover, target_date_for
|
||||
|
||||
TZ = ZoneInfo("Europe/Berlin")
|
||||
|
||||
|
||||
def _now(h, m=0):
|
||||
return datetime(2026, 7, 14, h, m, tzinfo=TZ)
|
||||
|
||||
|
||||
def test_before_switchover_is_today() -> None:
|
||||
d, tomorrow = target_date_for(_now(9, 59), "10:00:00")
|
||||
assert d == date(2026, 7, 14) and tomorrow is False
|
||||
|
||||
|
||||
def test_at_switchover_is_tomorrow_half_open() -> None:
|
||||
d, tomorrow = target_date_for(_now(10, 0), "10:00:00")
|
||||
assert d == date(2026, 7, 15) and tomorrow is True
|
||||
|
||||
|
||||
def test_after_switchover_is_tomorrow() -> None:
|
||||
d, tomorrow = target_date_for(_now(20, 0), "10:00:00")
|
||||
assert d == date(2026, 7, 15) and tomorrow is True
|
||||
|
||||
|
||||
def test_configurable_switchover() -> None:
|
||||
d, _ = target_date_for(_now(7, 0), "08:00:00")
|
||||
assert d == date(2026, 7, 14)
|
||||
d2, _ = target_date_for(_now(8, 0), "08:00:00")
|
||||
assert d2 == date(2026, 7, 15)
|
||||
|
||||
|
||||
def test_parse_switchover_fallback() -> None:
|
||||
from datetime import time
|
||||
|
||||
assert parse_switchover("bad") == time(10, 0, 0)
|
||||
assert parse_switchover("06:30:00") == time(6, 30, 0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue