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

View file

@ -205,8 +205,15 @@ der Wetterprognose, optional sprachlich schön formuliert, ausgespielt über Das
(`ARCHITECTURE-SPINE.md` final, 24 ADs; `.memlog.md` 43 Einträge; `reviews/` 6 Linsen). Paradigma: (`ARCHITECTURE-SPINE.md` final, 24 ADs; `.memlog.md` 43 Einträge; `reviews/` 6 Linsen). Paradigma:
Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale. Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale.
## Stories ## Stories (Phase 5 — Story-Loop; TDD, luna-pro-Review je Story, ein Commit je Story)
- (noch keine — folgen aus Phase 4 Epics/Stories) > qwen offline → Story-Reviews mit luna-pro (dokumentierter Ersatz). Umgebung: `.venv` Py3.13 + HA 2025.3.4.
- **1.1 Skeleton/Model/const/Guard** ✅ — 19 Tests grün. Dateien: `logic/model.py`, `const.py`,
`__init__.py`, `logic/__init__.py`, `manifest.json`, `hacs.json`, `tests/{test_layering,
test_const_schema, logic/test_model}.py`. **luna-pro-Review:** 6 Findings, 4 übernommen
(default_options()-Deepcopy F3; condition eigenes Feld F4; gaps_count/alert abgeleitet statt
gespeichert F5; Guard auf AST F6), 2 verworfen mit Evidenz (F1 RawForecast transient/nicht
signiert → keine Tief-Immutabilität nötig; F2 Schema-Validierung ist Story 3.1/3.2-Scope).
## Offene Punkte / nächste Schritte ## Offene Punkte / nächste Schritte
- Phase 4: Epics & Stories aus dem Spine + Party-Mode + Readiness-Check. - Phase 4: Epics & Stories aus dem Spine + Party-Mode + Readiness-Check.

View file

@ -0,0 +1,10 @@
"""What to Wear — a Home Assistant custom integration.
Story 1.1 provides only the package skeleton and the pure-core model/constants.
The config-entry lifecycle (``async_setup_entry`` etc.) arrives in Story 1.8.
"""
from __future__ import annotations
from .const import DOMAIN
__all__ = ["DOMAIN"]

View file

@ -0,0 +1,87 @@
"""Constants and the single normative options schema for What to Wear (AD-23).
This is the one place the flat, English options schema, its defaults, the
diagnostics redaction set, timeouts and provider URLs are defined. Every later
story wires against these constants rather than rebuilding the schema.
"""
from __future__ import annotations
import copy
from typing import Any, Final
DOMAIN: Final = "what_to_wear"
# Config-entry schema version (AD-8 / NFR-10).
CONFIG_VERSION: Final = 1
CONFIG_MINOR_VERSION: Final = 1
# entry.data key (AD-8).
CONF_WEATHER_ENTITY: Final = "weather_entity_id"
# Subentry type for wardrobe items (AD-9/AD-22).
SUBENTRY_TYPE_ITEM: Final = "item"
# Canonical entities/events/services.
SENSOR_ENTITY_ID: Final = "sensor.what_to_wear"
SENSOR_UNIQUE_ID: Final = "what_to_wear_recommendation"
EVENT_RECOMMENDATION: Final = "what_to_wear_recommendation"
SERVICE_RECOMMEND: Final = "recommend"
# --- Normative options schema (flat, English) — AD-23 -----------------------
# Base threshold values WITHOUT the offset; rules.py applies the offset once.
OPTIONS_DEFAULTS: Final[dict[str, Any]] = {
# Strictly monotone lower band edges in °C: bands are
# (-inf,0) [0,8) [8,15) [15,22) [>=22). Stored as the four inner edges.
"warmth_band_limits": [0.0, 8.0, 15.0, 22.0],
"heat_threshold": 28.0,
"rain_prob_should": 40, # %
"rain_prob_must": 70, # %
"rain_amount_should": 1.0, # mm
"rain_amount_must": 5.0, # mm
"gust_should": 40.0, # km/h
"wind_proxy_should": 30.0, # km/h (proxy when gusts missing)
"uv_should": 6,
"cold_sensitivity_offset": 0, # -2..+2
"switchover_time": "10:00:00", # HH:MM:SS (TimeSelector format)
"llm_enabled": False,
"llm_provider": "openai", # "openai" | "anthropic"
"llm_api_key": "",
"llm_model": "",
}
def default_options() -> dict[str, Any]:
"""Return a fresh deep copy of the option defaults (AD-23).
``OPTIONS_DEFAULTS`` is a template; consumers must never mutate it in place
(the nested ``warmth_band_limits`` list would otherwise leak across entries
and tests). Always start from this copy.
"""
return copy.deepcopy(OPTIONS_DEFAULTS)
# Diagnostics redaction — exact stored key names (AD-5).
TO_REDACT: Final = {"llm_api_key", CONF_WEATHER_ENTITY}
# --- Timeouts (seconds) -----------------------------------------------------
FORECAST_TIMEOUT_S: Final = 10 # coordinator forecast fetch (AD-7/AD-17)
CONFIG_FLOW_TEST_TIMEOUT_S: Final = 10 # config-flow test call (FR-1.2)
LLM_TIMEOUT_S: Final = 18 # AD-6, budget 10 + 18 + overhead < 30 s (NFR-7)
LLM_MAX_RESPONSE_BYTES: Final = 64 * 1024 # AD-6 body cap
# Recompute cadence / staleness.
UPDATE_INTERVAL_HOURS: Final = 1
STALE_AFTER_HOURS: Final = 6 # FR-7.5
# Output size limits (AD-14).
MAX_STATE_LEN: Final = 255
MAX_ATTRS_BYTES: Final = 15 * 1024 # buffer under the 16 KiB recorder limit
MAX_EVENT_BYTES: Final = 31 * 1024 # buffer under the 32 KiB event limit
MAX_ITEMS_IN_ATTRS: Final = 30
# --- LLM provider endpoints (AD-6) ------------------------------------------
OPENAI_URL: Final = "https://api.openai.com/v1/chat/completions"
ANTHROPIC_URL: Final = "https://api.anthropic.com/v1/messages"
ANTHROPIC_VERSION: Final = "2023-06-01"
# Model defaults (as of 2026-07; verify against the live model list on wiring).
DEFAULT_MODEL: Final = {"openai": "gpt-5.4-mini", "anthropic": "claude-haiku-4-5"}

View file

@ -0,0 +1,6 @@
"""Pure, hass-free recommendation core for What to Wear (AD-1).
Modules in this package import only the standard library. The layering guard
test (``tests/test_layering.py``) enforces that no ``homeassistant`` or
``dt_util`` import ever appears here.
"""

View file

@ -0,0 +1,199 @@
"""Pure-core data model for What to Wear (Story 1.1).
This module is part of the hass-free ``logic/`` core (AD-1): it imports only the
standard library. All shared shapes between the pipeline stages live here as
frozen dataclasses (AD-14), and the requirement/category vocabularies are the
single normative source of keys (AD-21/AD-22).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, IntEnum
class Priority(IntEnum):
"""Requirement priority. Ordered so ``max(...)`` yields the strongest (AD-21)."""
MAY = 1
SHOULD = 2
MUST = 3
class RequirementKey(str, Enum):
"""The single normative source of requirement keys (AD-21)."""
BASE_TOP = "base_top"
BASE_BOTTOM = "base_bottom"
BASE_SHOES = "base_shoes"
WARMTH = "warmth"
WATERPROOF_OUTER = "waterproof_outer"
WINDPROOF_OUTER = "windproof_outer"
STURDY_SHOES = "sturdy_shoes"
HAT = "hat"
GLOVES = "gloves"
SCARF = "scarf"
SUN_PROTECTION = "sun_protection"
HINT_HEAT = "hint_heat"
HINT_THUNDERSTORM = "hint_thunderstorm"
HINT_LAYERING = "hint_layering"
class Category(str, Enum):
"""The single normative source of wardrobe categories (AD-22)."""
TOP = "top"
SWEATER = "sweater"
JACKET = "jacket"
BOTTOM = "bottom"
SHOES = "shoes"
HEAD = "head"
HANDS = "hands"
NECK = "neck"
ACCESSORY = "accessory"
@dataclass(frozen=True, slots=True)
class NormField:
"""One normalized forecast field. ``value=None`` means missing, never 0 (AD-4)."""
value: float | None
source: str | None # "daily" | "hourly" | "derived" | None
note: str | None = None
@dataclass(frozen=True, slots=True)
class RawForecast:
"""Adapter output: timezone-aware, still in the source entity's units (AD-24).
``daily``/``hourly`` are tuples of plain dicts whose datetime values are
already ``datetime`` objects (tz-aware). ``units`` carries the source
entity's unit attributes so ``normalize`` can convert to SI (AD-4).
This is a transient carrier consumed once by ``normalize`` and then
discarded; it is never cached or fed into the signature (which is computed
over the composed outfit, not the raw forecast). Deep-freezing the nested
dicts is therefore deliberately omitted.
"""
time_zone: str
units: dict[str, str] = field(default_factory=dict)
daily: tuple[dict, ...] = ()
hourly: tuple[dict, ...] = ()
@dataclass(frozen=True, slots=True)
class NormForecast:
"""SI-normalized forecast for the target date, one NormField per feature."""
feels_like_morning: NormField
temp_morning: NormField
temp_min: NormField
temp_max: NormField
feels_like_min: NormField
feels_like_max: NormField
rain_probability: NormField
rain_amount: NormField
wind: NormField
gust: NormField
uv_index: NormField
# Weather condition is categorical, not numeric: a mapped code (e.g. "regen",
# "schnee", "gewitter") or None when missing/unmapped (AD-17).
condition: str | None = None
@dataclass(frozen=True, slots=True)
class Requirement:
"""A weather-derived requirement (AD-21). ``level`` carries N for warmth."""
key: RequirementKey
priority: Priority
level: int | None = None
@dataclass(frozen=True, slots=True)
class Item:
"""A wardrobe item (subentry). Keys are English per AD-22."""
id: str
name: str
category: Category
warmth: int
waterproof: bool = False
windproof: bool = False
sun_protection: bool = False
formality: str = "casual" # "casual" | "business"
temp_min: float | None = None
temp_max: float | None = None
active: bool = True
@dataclass(frozen=True, slots=True)
class ChosenItem:
"""An item selected into the outfit, with the requirement it satisfies."""
item: Item
reason_key: RequirementKey
@dataclass(frozen=True, slots=True)
class Gap:
"""A must/should requirement with no matching active item (FR-5.2)."""
key: RequirementKey
priority: Priority
cause: str
@dataclass(frozen=True, slots=True)
class Outfit:
"""The composed outfit plus named gaps and text hints."""
items: tuple[ChosenItem, ...] = ()
gaps: tuple[Gap, ...] = ()
hints: tuple[RequirementKey, ...] = ()
@dataclass(frozen=True, slots=True)
class Recommendation:
"""The immutable end-to-end recommendation (AD-14).
Enumerates every stored field. ``changed`` and ``stale`` are placeholders
the HA-side owners (coordinator / sensor) populate; ``signature`` is computed
in the pure core. The remaining AD-19 payload keys ``gaps_count`` and
``alert`` are *derived* in ``to_payload()`` (Story 1.5) from ``gaps`` /
``requirements`` so they can never desync they are not stored fields.
"""
status: str # "ok" | "fehler_prognose"
target_date: str # local calendar date, YYYY-MM-DD
language: str
target_label: str = ""
created_at: str | None = None
forecast_fetched_at: str | None = None
tone: str = "rules" # "rules" | "llm"
short_text: str = ""
full_text: str = ""
llm_text: str | None = None
items: tuple[ChosenItem, ...] = ()
items_truncated: int = 0
requirements: tuple[Requirement, ...] = ()
gaps: tuple[Gap, ...] = ()
metrics: NormForecast | None = None
data_notes: tuple[str, ...] = ()
source: str | None = None
signature: str = ""
changed: bool = False
stale: bool = False
@property
def gaps_count(self) -> int:
"""Derived payload key (AD-19): never desyncs from ``gaps``."""
return len(self.gaps)
@property
def alert(self) -> bool:
"""Derived payload key (AD-19): a must-requirement beyond the base outfit."""
base = {RequirementKey.BASE_TOP, RequirementKey.BASE_BOTTOM, RequirementKey.BASE_SHOES}
return any(
r.priority is Priority.MUST and r.key not in base for r in self.requirements
)

View file

@ -0,0 +1,14 @@
{
"domain": "what_to_wear",
"name": "What to Wear",
"version": "0.1.0",
"codeowners": ["@kenearos"],
"config_flow": true,
"dependencies": ["http", "frontend", "lovelace"],
"documentation": "https://github.com/kenearos/what_to_wear",
"integration_type": "service",
"iot_class": "calculated",
"issue_tracker": "https://github.com/kenearos/what_to_wear/issues",
"requirements": [],
"single_config_entry": true
}

5
hacs.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "What to Wear",
"homeassistant": "2025.3.0",
"render_readme": true
}

19
pyproject.toml Normal file
View file

@ -0,0 +1,19 @@
[project]
name = "what-to-wear-dev"
version = "0.0.0"
description = "Dev/test tooling for the What to Wear Home Assistant integration"
requires-python = ">=3.13"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "-q"
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

0
tests/__init__.py Normal file
View file

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

View file

@ -0,0 +1,69 @@
"""Story 1.1 — the normative options schema lives once in const.py (AD-23)."""
from __future__ import annotations
from custom_components.what_to_wear import const
def test_domain() -> None:
assert const.DOMAIN == "what_to_wear"
def test_options_schema_keys_flat_english() -> None:
keys = set(const.OPTIONS_DEFAULTS)
expected = {
"warmth_band_limits",
"heat_threshold",
"rain_prob_should",
"rain_prob_must",
"rain_amount_should",
"rain_amount_must",
"gust_should",
"wind_proxy_should",
"uv_should",
"cold_sensitivity_offset",
"switchover_time",
"llm_enabled",
"llm_provider",
"llm_api_key",
"llm_model",
}
assert expected <= keys, f"missing option keys: {expected - keys}"
def test_warmth_band_limits_strictly_monotone_length_four() -> None:
limits = const.OPTIONS_DEFAULTS["warmth_band_limits"]
assert len(limits) == 4
assert all(a < b for a, b in zip(limits, limits[1:])), "must be strictly monotone"
def test_defaults_sane() -> None:
d = const.OPTIONS_DEFAULTS
assert d["cold_sensitivity_offset"] == 0
assert d["llm_enabled"] is False
assert d["switchover_time"] == "10:00:00"
assert d["heat_threshold"] == 28.0
def test_default_options_returns_independent_deep_copy() -> None:
a = const.default_options()
b = const.default_options()
a["warmth_band_limits"].append(99.0)
a["cold_sensitivity_offset"] = 2
# Mutating one copy must not affect another copy nor the template.
assert b["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0]
assert b["cold_sensitivity_offset"] == 0
assert const.OPTIONS_DEFAULTS["warmth_band_limits"] == [0.0, 8.0, 15.0, 22.0]
def test_to_redact_uses_real_key_names() -> None:
# AD-5: redaction constant must match the real stored key names.
assert const.TO_REDACT == {"llm_api_key", "weather_entity_id"}
def test_timeouts_and_urls_present() -> None:
assert const.FORECAST_TIMEOUT_S == 10
assert const.LLM_TIMEOUT_S == 18
assert const.LLM_MAX_RESPONSE_BYTES == 64 * 1024
assert const.OPENAI_URL.startswith("https://api.openai.com/")
assert const.ANTHROPIC_URL.startswith("https://api.anthropic.com/")
assert const.ANTHROPIC_VERSION == "2023-06-01"

62
tests/test_layering.py Normal file
View file

@ -0,0 +1,62 @@
"""Story 1.1 — architectural layering guard (AD-1).
The pure core under ``logic/`` must never import Home Assistant (nor ``dt_util``).
This is a static AST scan of import statements, so it holds even though the test
environment has Home Assistant installed, and it does not false-positive on the
strings "import homeassistant" appearing inside docstrings or data.
"""
from __future__ import annotations
import ast
import pathlib
LOGIC_DIR = (
pathlib.Path(__file__).parent.parent
/ "custom_components"
/ "what_to_wear"
/ "logic"
)
def _forbidden_imports(source: str) -> list[str]:
"""Return the offending imported module names in ``source`` (AST-based)."""
offenders: list[str] = []
tree = ast.parse(source)
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
# module is None for "from . import x"; treat as empty
names = [node.module or ""]
names += [alias.name for alias in node.names]
for name in names:
root = name.split(".")[0]
if root == "homeassistant" or name == "dt_util" or "dt_util" in name:
offenders.append(name)
return offenders
def test_logic_has_no_homeassistant_imports() -> None:
py_files = list(LOGIC_DIR.rglob("*.py"))
assert py_files, "logic/ must contain Python modules"
offenders: dict[str, list[str]] = {}
for path in py_files:
found = _forbidden_imports(path.read_text(encoding="utf-8"))
if found:
offenders[str(path.relative_to(LOGIC_DIR.parent))] = found
assert not offenders, f"logic/ must not import homeassistant/dt_util: {offenders}"
def test_guard_catches_violations_and_ignores_strings() -> None:
# Real imports are caught.
assert _forbidden_imports("import homeassistant\n")
assert _forbidden_imports("from homeassistant.core import HomeAssistant\n")
assert _forbidden_imports("from homeassistant.util import dt as dt_util\n")
assert _forbidden_imports("import homeassistant.util.dt as dt_util\n")
# Stdlib imports are fine.
assert not _forbidden_imports("import datetime\n")
assert not _forbidden_imports("from zoneinfo import ZoneInfo\n")
# A docstring merely *mentioning* the words is NOT a violation (no false positive).
assert not _forbidden_imports('"""We must never import homeassistant here."""\n')
assert not _forbidden_imports('X = "from homeassistant.core import HomeAssistant"\n')