Story 1.1 (TDD, Suite gruen 19/19): - logic/model.py: frozen Dataclasses (NormField, RawForecast, NormForecast, Requirement, Item, ChosenItem, Gap, Outfit, Recommendation) + Enums RequirementKey/Category/Priority (AD-14/21/22). - const.py: normatives flaches Options-Schema + default_options()-Deepcopy, TO_REDACT, Timeouts, LLM-URLs (AD-5/6/23). - manifest.json/hacs.json (min HA 2025.3), Layering-Guard per AST (AD-1). - luna-pro-Story-Review: 4/6 Findings uebernommen, 2 verworfen (Evidenz im Ledger). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
4.8 KiB
Python
157 lines
4.8 KiB
Python
"""Story 1.1 — pure-core data model and enums (no Home Assistant harness)."""
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
|
|
import pytest
|
|
|
|
from custom_components.what_to_wear.logic import model as m
|
|
|
|
|
|
def test_requirement_key_enum_complete() -> None:
|
|
expected = {
|
|
"base_top",
|
|
"base_bottom",
|
|
"base_shoes",
|
|
"warmth",
|
|
"waterproof_outer",
|
|
"windproof_outer",
|
|
"sturdy_shoes",
|
|
"hat",
|
|
"gloves",
|
|
"scarf",
|
|
"sun_protection",
|
|
"hint_heat",
|
|
"hint_thunderstorm",
|
|
"hint_layering",
|
|
}
|
|
assert {k.value for k in m.RequirementKey} == expected
|
|
|
|
|
|
def test_category_enum_complete() -> None:
|
|
expected = {
|
|
"top",
|
|
"sweater",
|
|
"jacket",
|
|
"bottom",
|
|
"shoes",
|
|
"head",
|
|
"hands",
|
|
"neck",
|
|
"accessory",
|
|
}
|
|
assert {c.value for c in m.Category} == expected
|
|
|
|
|
|
def test_priority_ordering_for_merge() -> None:
|
|
# max(priority) must pick MUST over SHOULD over MAY (AD-21 merge semantics)
|
|
assert m.Priority.MUST > m.Priority.SHOULD > m.Priority.MAY
|
|
assert max(m.Priority.SHOULD, m.Priority.MUST) is m.Priority.MUST
|
|
|
|
|
|
def test_normfield_missing_is_none_not_zero() -> None:
|
|
missing = m.NormField(value=None, source=None, note="missing")
|
|
present = m.NormField(value=0.0, source="daily", note=None)
|
|
assert missing.value is None
|
|
assert present.value == 0.0
|
|
# "fehlend != 0": the two must be distinguishable
|
|
assert missing != present
|
|
|
|
|
|
def test_core_dataclasses_exist_and_are_frozen() -> None:
|
|
for name in (
|
|
"NormField",
|
|
"RawForecast",
|
|
"NormForecast",
|
|
"Requirement",
|
|
"Item",
|
|
"Outfit",
|
|
"Recommendation",
|
|
):
|
|
cls = getattr(m, name)
|
|
assert dataclasses.is_dataclass(cls), f"{name} must be a dataclass"
|
|
params = cls.__dataclass_params__
|
|
assert params.frozen, f"{name} must be frozen (AD-14 immutability)"
|
|
|
|
|
|
def test_recommendation_is_immutable() -> None:
|
|
rec = m.Recommendation(status="ok", target_date="2026-07-13", language="de")
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
rec.status = "fehler_prognose" # type: ignore[misc]
|
|
|
|
|
|
def test_recommendation_enumerates_payload_fields() -> None:
|
|
# AD-19: the stored fields, incl. HA-populated placeholders changed/stale and
|
|
# the core-computed signature. gaps_count/alert are derived (see below).
|
|
field_names = {f.name for f in dataclasses.fields(m.Recommendation)}
|
|
required = {
|
|
"status",
|
|
"target_date",
|
|
"target_label",
|
|
"created_at",
|
|
"forecast_fetched_at",
|
|
"language",
|
|
"tone",
|
|
"short_text",
|
|
"full_text",
|
|
"llm_text",
|
|
"items",
|
|
"items_truncated",
|
|
"requirements",
|
|
"gaps",
|
|
"metrics",
|
|
"data_notes",
|
|
"source",
|
|
"signature",
|
|
"changed",
|
|
"stale",
|
|
}
|
|
assert required <= field_names, f"missing: {required - field_names}"
|
|
|
|
|
|
def test_gaps_count_and_alert_are_derived_not_stored() -> None:
|
|
# AD-19: derived at projection time so they can never desync (Story 1.5 uses them).
|
|
stored = {f.name for f in dataclasses.fields(m.Recommendation)}
|
|
assert "gaps_count" not in stored
|
|
assert "alert" not in stored
|
|
|
|
no_gaps = m.Recommendation(status="ok", target_date="2026-07-13", language="de")
|
|
assert no_gaps.gaps_count == 0
|
|
assert no_gaps.alert is False
|
|
|
|
gap = m.Gap(key=m.RequirementKey.GLOVES, priority=m.Priority.SHOULD, cause="none")
|
|
must_req = m.Requirement(key=m.RequirementKey.WATERPROOF_OUTER, priority=m.Priority.MUST)
|
|
base_req = m.Requirement(key=m.RequirementKey.BASE_TOP, priority=m.Priority.MUST)
|
|
rec = m.Recommendation(
|
|
status="ok",
|
|
target_date="2026-07-13",
|
|
language="de",
|
|
gaps=(gap,),
|
|
requirements=(base_req, must_req),
|
|
)
|
|
assert rec.gaps_count == 1
|
|
assert rec.alert is True # a MUST beyond the base outfit
|
|
|
|
only_base = m.Recommendation(
|
|
status="ok", target_date="2026-07-13", language="de", requirements=(base_req,)
|
|
)
|
|
assert only_base.alert is False # base-only MUSTs do not raise alert
|
|
|
|
|
|
def test_recommendation_defaults_for_ha_populated_fields() -> None:
|
|
rec = m.Recommendation(status="ok", target_date="2026-07-13", language="en")
|
|
# changed/stale are placeholders the coordinator/sensor fill later (AD-19)
|
|
assert rec.changed is False
|
|
assert rec.stale is False
|
|
assert rec.tone == "rules"
|
|
|
|
|
|
def test_item_defaults() -> None:
|
|
item = m.Item(id="abc", name="Jeans", category=m.Category.BOTTOM, warmth=3)
|
|
assert item.active is True
|
|
assert item.waterproof is False
|
|
assert item.windproof is False
|
|
assert item.sun_protection is False
|
|
assert item.formality == "casual"
|
|
assert item.temp_min is None
|
|
assert item.temp_max is None
|