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
11
LEDGER.md
11
LEDGER.md
|
|
@ -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.
|
||||||
|
|
|
||||||
10
custom_components/what_to_wear/__init__.py
Normal file
10
custom_components/what_to_wear/__init__.py
Normal 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"]
|
||||||
87
custom_components/what_to_wear/const.py
Normal file
87
custom_components/what_to_wear/const.py
Normal 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"}
|
||||||
6
custom_components/what_to_wear/logic/__init__.py
Normal file
6
custom_components/what_to_wear/logic/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
199
custom_components/what_to_wear/logic/model.py
Normal file
199
custom_components/what_to_wear/logic/model.py
Normal 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
|
||||||
|
)
|
||||||
14
custom_components/what_to_wear/manifest.json
Normal file
14
custom_components/what_to_wear/manifest.json
Normal 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
5
hacs.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "What to Wear",
|
||||||
|
"homeassistant": "2025.3.0",
|
||||||
|
"render_readme": true
|
||||||
|
}
|
||||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal 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
0
tests/__init__.py
Normal file
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
|
||||||
69
tests/test_const_schema.py
Normal file
69
tests/test_const_schema.py
Normal 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
62
tests/test_layering.py
Normal 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')
|
||||||
Loading…
Add table
Add a link
Reference in a new issue