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:
|
||||
Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale.
|
||||
|
||||
## Stories
|
||||
- (noch keine — folgen aus Phase 4 Epics/Stories)
|
||||
## Stories (Phase 5 — Story-Loop; TDD, luna-pro-Review je Story, ein Commit je Story)
|
||||
> 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
|
||||
- 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