Story 1.6 (TDD, phcc, Suite gruen 79/79): - weather/provider.py: runtime-checkable Protocol WeatherProvider (INV-6). - weather/ha_entity.py: get_forecasts (blocking+return_response), Feature-Check (nur twice_daily->ForecastUnsupported), dt_util-Parsing/Verwerfen naiver Zeit- stempel, Einheiten aus State-Attributen (AD-17/24); defensive Struktur-Guards. - tests/conftest.py: phcc enable_custom_integrations. - luna-pro-Story-Review: 4/4 Findings uebernommen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.8 KiB
Python
124 lines
4.8 KiB
Python
"""Story 1.6 — weather adapter (WeatherProvider port + HA entity impl)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from homeassistant.components.weather import WeatherEntityFeature
|
|
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
from custom_components.what_to_wear.logic import normalize
|
|
from custom_components.what_to_wear.logic.model import RawForecast
|
|
from custom_components.what_to_wear.weather import provider as provider_mod
|
|
from custom_components.what_to_wear.weather.errors import ForecastUnsupported
|
|
from custom_components.what_to_wear.weather.ha_entity import HAEntityProvider
|
|
|
|
ENTITY = "weather.home"
|
|
|
|
|
|
def _set_weather(hass: HomeAssistant, features: int, attrs: dict | None = None) -> None:
|
|
base = {
|
|
"temperature_unit": "°C",
|
|
"wind_speed_unit": "km/h",
|
|
"precipitation_unit": "mm",
|
|
"supported_features": features,
|
|
}
|
|
if attrs:
|
|
base.update(attrs)
|
|
hass.states.async_set(ENTITY, "cloudy", base)
|
|
|
|
|
|
def _register_forecast(hass: HomeAssistant, by_type: dict | None = None, raises=False, response=None):
|
|
"""Register a mock weather.get_forecasts service."""
|
|
|
|
async def handler(call) -> ServiceResponse:
|
|
if raises:
|
|
raise HomeAssistantError("entity unavailable")
|
|
if response is not None:
|
|
return response
|
|
ftype = call.data.get("type")
|
|
entries = (by_type or {}).get(ftype, [])
|
|
return {call.data["entity_id"]: {"forecast": entries}}
|
|
|
|
hass.services.async_register(
|
|
"weather", "get_forecasts", handler, supports_response=SupportsResponse.ONLY
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_protocol_shape() -> None:
|
|
# The port is a runtime-checkable Protocol with the async method.
|
|
assert hasattr(provider_mod, "WeatherProvider")
|
|
assert hasattr(HAEntityProvider, "get_raw_forecast")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_daily_and_hourly_fetch_builds_rawforecast(hass: HomeAssistant) -> None:
|
|
_set_weather(hass, WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY)
|
|
daily = [{"datetime": "2026-07-14T12:00:00+00:00", "temperature": 20.0, "condition": "rainy"}]
|
|
hourly = [{"datetime": "2026-07-14T05:00:00+00:00", "temperature": 11.0}]
|
|
_register_forecast(hass, by_type={"daily": daily, "hourly": hourly})
|
|
|
|
prov = HAEntityProvider(hass, ENTITY)
|
|
raw = await prov.get_raw_forecast()
|
|
|
|
assert isinstance(raw, RawForecast)
|
|
assert raw.units["temperature"] == "°C"
|
|
assert len(raw.daily) == 1 and len(raw.hourly) == 1
|
|
# datetimes are parsed to tz-aware datetime objects (AD-24).
|
|
assert isinstance(raw.daily[0]["datetime"], datetime)
|
|
assert raw.daily[0]["datetime"].tzinfo is not None
|
|
# and the RawForecast feeds normalize without error.
|
|
nf = normalize.normalize(raw, datetime(2026, 7, 14, tzinfo=timezone.utc).date())
|
|
assert nf.temp_max.value == 20.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_twice_daily_only_is_unsupported(hass: HomeAssistant) -> None:
|
|
_set_weather(hass, WeatherEntityFeature.FORECAST_TWICE_DAILY)
|
|
prov = HAEntityProvider(hass, ENTITY)
|
|
with pytest.raises(ForecastUnsupported):
|
|
await prov.get_raw_forecast()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_entity_is_unsupported(hass: HomeAssistant) -> None:
|
|
prov = HAEntityProvider(hass, "weather.does_not_exist")
|
|
with pytest.raises(ForecastUnsupported):
|
|
await prov.get_raw_forecast()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_error_propagates(hass: HomeAssistant) -> None:
|
|
_set_weather(hass, WeatherEntityFeature.FORECAST_DAILY)
|
|
_register_forecast(hass, raises=True)
|
|
prov = HAEntityProvider(hass, ENTITY)
|
|
with pytest.raises(HomeAssistantError):
|
|
await prov.get_raw_forecast()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparsable_datetime_entries_dropped(hass: HomeAssistant) -> None:
|
|
_set_weather(hass, WeatherEntityFeature.FORECAST_DAILY)
|
|
daily = [
|
|
{"datetime": "2026-07-14T12:00:00+00:00", "temperature": 20.0},
|
|
{"datetime": "not-a-date", "temperature": 99.0},
|
|
{"temperature": 88.0}, # no datetime at all
|
|
]
|
|
_register_forecast(hass, by_type={"daily": daily})
|
|
prov = HAEntityProvider(hass, ENTITY)
|
|
raw = await prov.get_raw_forecast()
|
|
|
|
assert len(raw.daily) == 1 # only the parseable entry survives
|
|
assert raw.daily[0]["temperature"] == 20.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_response_key_is_unsupported(hass: HomeAssistant) -> None:
|
|
# unavailable entity -> service returns without our key.
|
|
_set_weather(hass, WeatherEntityFeature.FORECAST_DAILY)
|
|
_register_forecast(hass, response={}) # key missing
|
|
prov = HAEntityProvider(hass, ENTITY)
|
|
raw = await prov.get_raw_forecast()
|
|
assert raw.daily == () # gracefully empty, normalize will yield fehler upstream
|