Files
EOS/tests/test_pvforecasthomeassistant.py
T

227 lines
9.1 KiB
Python
Raw Normal View History

from unittest.mock import MagicMock, call, patch
import pendulum
import pytest
import requests
from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant
# Fixed "current" EMS time so tests can assert on the [ems_start_datetime, end_datetime)
# window that _update_data clears before writing, independent of wall-clock time.
FIXED_EMS_START = pendulum.datetime(2026, 8, 18, 5, 0, tz="Europe/Berlin")
# Trimmed excerpt of a real Home Assistant response, captured live from a
# Helios Forecast "Power now" sensor (sensor.pv1_power_now).
REAL_HA_RESPONSE = {
"entity_id": "sensor.pv1_power_now",
"state": "0.0",
"attributes": {
"state_class": "measurement",
"forecast": [
{"datetime": "2026-08-18T06:00:00+02:00", "watts": 0},
{"datetime": "2026-08-18T06:15:00+02:00", "watts": 0},
{"datetime": "2026-08-18T06:30:00+02:00", "watts": 17.19},
{"datetime": "2026-08-18T06:45:00+02:00", "watts": 43.24},
{"datetime": "2026-08-18T07:00:00+02:00", "watts": 75.68},
],
"unit_of_measurement": "W",
"device_class": "power",
"friendly_name": "PV1 Power now",
},
}
@pytest.fixture
def pvforecast_instance(config_eos):
settings = {
"pvforecast": {
"homeassistant": {
"entity_id": "sensor.pv1_power_now",
"base_url": "http://homeassistant.local:8123",
"token": "dummy-token",
},
}
}
config_eos.merge_settings_from_dict(settings)
start_dt = pendulum.datetime(2026, 8, 18, tz="Europe/Berlin")
return PVForecastHomeAssistant(config=config_eos.load, start_datetime=start_dt)
def mock_response(json_data, status_code=200):
mock = MagicMock(spec=requests.Response)
mock.status_code = status_code
mock.json.return_value = json_data
return mock
@pytest.mark.asyncio
async def test_update_data_updates_ac_power(pvforecast_instance):
with (
patch("requests.get", return_value=mock_response(REAL_HA_RESPONSE)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
await pvforecast_instance._update_data()
assert mock_update.call_count == 5
expected_calls = [
call(pendulum.parse("2026-08-18T06:00:00+02:00"), {"pvforecast_ac_power": 0.0}),
call(pendulum.parse("2026-08-18T06:15:00+02:00"), {"pvforecast_ac_power": 0.0}),
call(pendulum.parse("2026-08-18T06:30:00+02:00"), {"pvforecast_ac_power": 17.19}),
call(pendulum.parse("2026-08-18T06:45:00+02:00"), {"pvforecast_ac_power": 43.24}),
call(pendulum.parse("2026-08-18T07:00:00+02:00"), {"pvforecast_ac_power": 75.68}),
]
mock_update.assert_has_calls(expected_calls, any_order=False)
@pytest.mark.asyncio
async def test_update_data_converts_kw_to_w(pvforecast_instance):
pvforecast_instance.config.pvforecast.homeassistant.value_unit = "kW"
response = {
"state": "0.0",
"attributes": {"forecast": [{"datetime": "2026-08-18T12:00:00+02:00", "watts": 2.5}]},
}
with (
patch("requests.get", return_value=mock_response(response)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
await pvforecast_instance._update_data()
mock_update.assert_called_once_with(
pendulum.parse("2026-08-18T12:00:00+02:00"), {"pvforecast_ac_power": 2500.0}
)
@pytest.mark.asyncio
async def test_update_data_raises_on_missing_attribute(pvforecast_instance):
response = {"state": "0.0", "attributes": {}}
with (
patch("requests.get", return_value=mock_response(response)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
with pytest.raises(ValueError, match="no 'forecast' attribute"):
await pvforecast_instance._update_data()
mock_update.assert_not_called()
@pytest.mark.asyncio
async def test_update_data_raises_on_empty_forecast(pvforecast_instance):
response = {"state": "0.0", "attributes": {"forecast": []}}
with (
patch("requests.get", return_value=mock_response(response)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
with pytest.raises(ValueError, match="no 'forecast' attribute"):
await pvforecast_instance._update_data()
mock_update.assert_not_called()
@pytest.mark.asyncio
async def test_update_data_raises_when_all_entries_malformed(pvforecast_instance):
"""A non-empty but entirely unusable forecast must fail explicitly, not succeed as a no-op."""
response = {
"state": "0.0",
"attributes": {
"forecast": [
{"datetime": "2026-08-18T06:00:00+02:00"}, # missing "watts"
{"watts": "not-a-number", "datetime": "2026-08-18T06:15:00+02:00"},
]
},
}
with (
patch("requests.get", return_value=mock_response(response)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
with pytest.raises(ValueError, match="no usable forecast entries"):
await pvforecast_instance._update_data()
mock_update.assert_not_called()
@pytest.mark.asyncio
async def test_update_data_clears_stale_entries_missing_from_shorter_forecast(
pvforecast_instance,
):
"""A shorter refresh must not leave stale values behind at now-omitted timestamps,
and must not touch retained historical records before the active forecast window.
Regression test for two bugs:
- An early return on empty/short responses left previously-written
pvforecast_ac_power values in place, letting EOS optimize against a mixture of
current and stale forecast data.
- The fix for that (clearing from start-of-day) then wiped out historical records
PredictionProvider deliberately retains (keep_datetime / prediction.historic_hours),
since Home Assistant forecasts are future-only and every refresh would blank
everything between midnight and the EMS start.
"""
with patch("akkudoktoreos.core.coreabc.get_ems") as mock_get_ems:
mock_get_ems.return_value.start_datetime = FIXED_EMS_START
# Seed a historical value from before the active window: must survive.
historical_dt = FIXED_EMS_START.subtract(hours=1)
await pvforecast_instance.update_value(historical_dt, {"pvforecast_ac_power": 321.0})
# Seed a value as if a previous, longer forecast had covered this timestamp.
stale_dt = pendulum.datetime(2026, 8, 18, 10, 0, tz="Europe/Berlin")
await pvforecast_instance.update_value(stale_dt, {"pvforecast_ac_power": 999.0})
shorter_response = {
"state": "0.0",
"attributes": {
"forecast": [{"datetime": "2026-08-18T06:00:00+02:00", "watts": 12.0}]
},
}
with patch("requests.get", return_value=mock_response(shorter_response)):
await pvforecast_instance._update_data()
values = {
pendulum.parse(dt): value
for dt, value in (
await pvforecast_instance.key_to_dict("pvforecast_ac_power", dropna=False)
).items()
}
assert values[historical_dt] == 321.0
assert values[stale_dt] is None
assert values[pendulum.parse("2026-08-18T06:00:00+02:00")] == 12.0
@pytest.mark.asyncio
async def test_update_data_skips_malformed_entries(pvforecast_instance):
response = {
"state": "0.0",
"attributes": {
"forecast": [
{"datetime": "2026-08-18T06:00:00+02:00", "watts": 12.0},
{"datetime": "2026-08-18T06:15:00+02:00"}, # missing "watts"
{"watts": "not-a-number", "datetime": "2026-08-18T06:30:00+02:00"},
{"datetime": "2026-08-18T06:45:00+02:00", "watts": 34.0},
]
},
}
with (
patch("requests.get", return_value=mock_response(response)),
patch.object(PVForecastHomeAssistant, "update_value") as mock_update,
):
await pvforecast_instance._update_data()
assert mock_update.call_count == 2
expected_calls = [
call(pendulum.parse("2026-08-18T06:00:00+02:00"), {"pvforecast_ac_power": 12.0}),
call(pendulum.parse("2026-08-18T06:45:00+02:00"), {"pvforecast_ac_power": 34.0}),
]
mock_update.assert_has_calls(expected_calls, any_order=False)
def test_request_entity_state_raises_on_http_error(pvforecast_instance):
with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get:
with pytest.raises(RuntimeError) as exc_info:
pvforecast_instance._request_entity_state()
assert "Failed to fetch pvforecast entity" in str(exc_info.value)
mock_get.assert_called_once()
def test_request_entity_state_raises_without_token(pvforecast_instance):
pvforecast_instance.config.pvforecast.homeassistant.token = None
with pytest.raises(RuntimeError) as exc_info:
pvforecast_instance._request_entity_state()
assert "No Home Assistant access token available" in str(exc_info.value)