feat(1.1): Skeleton, pure-core Datenmodell, Options-Schema & Layering-Guard

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>
This commit is contained in:
Nora 2026-07-13 13:20:42 +00:00
parent 2686f253d6
commit eff3fa28a9
13 changed files with 637 additions and 2 deletions

0
tests/logic/__init__.py Normal file
View file

157
tests/logic/test_model.py Normal file
View file

@ -0,0 +1,157 @@
"""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