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:
parent
2686f253d6
commit
eff3fa28a9
13 changed files with 637 additions and 2 deletions
0
tests/logic/__init__.py
Normal file
0
tests/logic/__init__.py
Normal file
157
tests/logic/test_model.py
Normal file
157
tests/logic/test_model.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue