mirror of
https://github.com/home-assistant/core.git
synced 2026-09-16 21:48:38 +01:00
846 lines
26 KiB
Python
846 lines
26 KiB
Python
"""Tests for the services provided by the EnergyZero integration."""
|
|
|
|
from datetime import UTC, date, datetime, timedelta
|
|
import re
|
|
from unittest.mock import AsyncMock, call
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from energyzero import (
|
|
EnergyPrices,
|
|
EnergyZeroNoDataError,
|
|
Interval,
|
|
PriceType,
|
|
TimeRange,
|
|
)
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.energyzero.const import (
|
|
CONF_ELECTRICITY_PRICE_INTERVAL,
|
|
DOMAIN,
|
|
)
|
|
from homeassistant.components.energyzero.services import (
|
|
ATTR_CONFIG_ENTRY,
|
|
ENERGY_SERVICE_NAME,
|
|
GAS_SERVICE_NAME,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
|
|
from tests.common import MockConfigEntry
|
|
|
|
pytestmark = pytest.mark.freeze_time("2026-04-10 20:32:59")
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "service_data"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
{"incl_vat": False},
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
{"incl_vat": True},
|
|
),
|
|
],
|
|
)
|
|
async def test_service(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
snapshot: SnapshotAssertion,
|
|
service: str,
|
|
service_data: dict[str, str | bool],
|
|
) -> None:
|
|
"""Test the EnergyZero Service."""
|
|
data = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} | service_data
|
|
|
|
assert snapshot == await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "incl_vat", "expected_price_type"),
|
|
[
|
|
(GAS_SERVICE_NAME, True, PriceType.MARKET_WITH_VAT),
|
|
(GAS_SERVICE_NAME, False, PriceType.MARKET),
|
|
(ENERGY_SERVICE_NAME, True, PriceType.MARKET_WITH_VAT),
|
|
(ENERGY_SERVICE_NAME, False, PriceType.MARKET),
|
|
],
|
|
)
|
|
async def test_service_price_type_mapping(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
incl_vat: bool,
|
|
expected_price_type: PriceType,
|
|
) -> None:
|
|
"""Test incl_vat maps to the expected EnergyZero price type."""
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": incl_vat,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert method.await_args.kwargs["price_type"] is expected_price_type
|
|
assert method.await_args.kwargs["local_tz"] == ZoneInfo(hass.config.time_zone)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
async def test_service_dates_normalized_to_hass_timezone(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
) -> None:
|
|
"""Test service input datetimes are normalized to the HA timezone."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
"start": "2023-01-01 23:30:00-01:00",
|
|
"end": "2023-01-02 00:30:00-01:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert method.await_args.kwargs["start_date"] == date(2023, 1, 2)
|
|
assert method.await_args.kwargs["local_tz"] == ZoneInfo("Europe/Amsterdam")
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "expected_prices"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
[
|
|
{
|
|
"price": 0.45193447944,
|
|
"timestamp": "2026-04-10 04:00:00+00:00",
|
|
"start": "2026-04-10 04:00:00+00:00",
|
|
"end": "2026-04-11 04:00:00+00:00",
|
|
}
|
|
],
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
[
|
|
{
|
|
"price": 0.12572,
|
|
"timestamp": "2026-04-10 21:00:00+00:00",
|
|
"start": "2026-04-10 21:00:00+00:00",
|
|
"end": "2026-04-10 22:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.125925,
|
|
"timestamp": "2026-04-10 22:00:00+00:00",
|
|
"start": "2026-04-10 22:00:00+00:00",
|
|
"end": "2026-04-10 23:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.1120525,
|
|
"timestamp": "2026-04-10 23:00:00+00:00",
|
|
"start": "2026-04-10 23:00:00+00:00",
|
|
"end": "2026-04-11 00:00:00+00:00",
|
|
},
|
|
],
|
|
),
|
|
],
|
|
)
|
|
async def test_service_filters_datetime_range(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
expected_prices: list[dict[str, str | float]],
|
|
) -> None:
|
|
"""Test services request each day and filter to the datetime range."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-04-10 23:00:00+02:00",
|
|
"end": "2026-04-11 02:00:00+02:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {"prices": expected_prices}
|
|
assert response["prices"]
|
|
assert all(item["timestamp"] == item["start"] for item in response["prices"])
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert [item.kwargs["start_date"] for item in method.await_args_list] == [
|
|
date(2026, 4, 10),
|
|
date(2026, 4, 11),
|
|
]
|
|
assert all(
|
|
item.kwargs["end_date"] == item.kwargs["start_date"]
|
|
for item in method.await_args_list
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
"equal_time", ["00:00:00", "14:30:00"], ids=["midnight", "non-midnight"]
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("service", "expected_count", "expected_first", "expected_last"),
|
|
[
|
|
(
|
|
GAS_SERVICE_NAME,
|
|
1,
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
},
|
|
),
|
|
(
|
|
ENERGY_SERVICE_NAME,
|
|
24,
|
|
{
|
|
"price": 0.125925,
|
|
"timestamp": "2026-04-10 22:00:00+00:00",
|
|
"start": "2026-04-10 22:00:00+00:00",
|
|
"end": "2026-04-10 23:00:00+00:00",
|
|
},
|
|
{
|
|
"price": 0.082385,
|
|
"timestamp": "2026-04-11 21:00:00+00:00",
|
|
"start": "2026-04-11 21:00:00+00:00",
|
|
"end": "2026-04-11 22:00:00+00:00",
|
|
},
|
|
),
|
|
],
|
|
)
|
|
async def test_service_equal_start_end_selects_full_local_day(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
equal_time: str,
|
|
expected_count: int,
|
|
expected_first: dict[str, str | float],
|
|
expected_last: dict[str, str | float],
|
|
) -> None:
|
|
"""Equal start/end datetimes select the full local calendar day."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
equal_datetime = f"2026-04-11 {equal_time}"
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": equal_datetime,
|
|
"end": equal_datetime,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert len(response["prices"]) == expected_count
|
|
assert response["prices"][0] == expected_first
|
|
assert response["prices"][-1] == expected_last
|
|
assert all(item["timestamp"] == item["start"] for item in response["prices"])
|
|
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
assert len(method.await_args_list) == 1
|
|
assert method.await_args.kwargs["start_date"] == date(2026, 4, 11)
|
|
assert method.await_args.kwargs["end_date"] == date(2026, 4, 11)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_service_equal_start_end_full_day_respects_dst(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Equal start/end selects a local calendar day, not a fixed 24h window."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
|
|
in_range = TimeRange(
|
|
datetime(2026, 3, 28, 23, 0, tzinfo=UTC),
|
|
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
|
|
)
|
|
out_of_range = TimeRange(
|
|
datetime(2026, 3, 29, 22, 0, tzinfo=UTC),
|
|
datetime(2026, 3, 29, 23, 0, tzinfo=UTC),
|
|
)
|
|
mock_energyzero.get_electricity_prices.side_effect = None
|
|
mock_energyzero.get_electricity_prices.return_value = EnergyPrices(
|
|
prices={in_range: 0.1, out_of_range: 0.2}, average_price=None
|
|
)
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
ENERGY_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-03-29 00:00:00",
|
|
"end": "2026-03-29 00:00:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {
|
|
"prices": [
|
|
{
|
|
"price": 0.1,
|
|
"timestamp": "2026-03-28 23:00:00+00:00",
|
|
"start": "2026-03-28 23:00:00+00:00",
|
|
"end": "2026-03-29 22:00:00+00:00",
|
|
}
|
|
]
|
|
}
|
|
assert mock_energyzero.get_electricity_prices.await_args.kwargs[
|
|
"start_date"
|
|
] == date(2026, 3, 29)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_service_date_and_equal_datetime_selects_full_local_day(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""A date-only start and a same-day midnight datetime end are equal."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
mock_energyzero.reset_mock()
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
GAS_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": False,
|
|
"start": "2026-04-11",
|
|
"end": "2026-04-11 00:00:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
assert response == {
|
|
"prices": [
|
|
{
|
|
"price": 0.430304930424,
|
|
"timestamp": "2026-04-11 04:00:00+00:00",
|
|
"start": "2026-04-11 04:00:00+00:00",
|
|
"end": "2026-04-12 04:00:00+00:00",
|
|
}
|
|
]
|
|
}
|
|
assert mock_energyzero.get_gas_prices.await_args.kwargs["start_date"] == date(
|
|
2026, 4, 11
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def config_entry_data(
|
|
mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest
|
|
) -> dict[str, str]:
|
|
"""Fixture for the config entry."""
|
|
if "config_entry" in request.param and request.param["config_entry"] is True:
|
|
return {"config_entry": mock_config_entry.entry_id}
|
|
|
|
return request.param
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
@pytest.mark.parametrize(
|
|
("config_entry_data", "service_data", "error", "error_message"),
|
|
[
|
|
({}, {}, vol.error.Error, "required key not provided .+"),
|
|
(
|
|
{"config_entry": True},
|
|
{},
|
|
vol.error.Error,
|
|
"required key not provided .+",
|
|
),
|
|
(
|
|
{},
|
|
{"incl_vat": True},
|
|
vol.error.Error,
|
|
"required key not provided .+",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{"incl_vat": "incorrect vat"},
|
|
vol.error.Error,
|
|
"expected bool at .+",
|
|
),
|
|
(
|
|
{"config_entry": "incorrect entry"},
|
|
{"incl_vat": True},
|
|
ServiceValidationError,
|
|
".+ config entry with ID incorrect entry was not found",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "incorrect date",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date provided. Got incorrect date",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"end": "incorrect date",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date provided. Got incorrect date",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "2023-01-02",
|
|
"end": "2023-01-01",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date range provided. End 2023-01-01 must be after start 2023-01-02",
|
|
),
|
|
(
|
|
{"config_entry": True},
|
|
{
|
|
"incl_vat": True,
|
|
"start": "2026-04-11 12:00:00",
|
|
"end": "2026-04-11 08:00:00",
|
|
},
|
|
ServiceValidationError,
|
|
"Invalid date range provided. End 2026-04-11 08:00:00 must be "
|
|
"after start 2026-04-11 12:00:00",
|
|
),
|
|
],
|
|
indirect=["config_entry_data"],
|
|
)
|
|
async def test_service_validation(
|
|
hass: HomeAssistant,
|
|
service: str,
|
|
config_entry_data: dict[str, str],
|
|
service_data: dict[str, str],
|
|
error: type[Exception],
|
|
error_message: str,
|
|
) -> None:
|
|
"""Test the EnergyZero Service validation."""
|
|
|
|
with pytest.raises(error) as exc:
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
config_entry_data | service_data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
assert re.match(error_message, str(exc.value))
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME])
|
|
async def test_service_called_with_unloaded_entry(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
) -> None:
|
|
"""Test service calls with unloaded config entry."""
|
|
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
|
|
|
data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True}
|
|
|
|
with pytest.raises(
|
|
ServiceValidationError,
|
|
match=f"{mock_config_entry.title} for integration energyzero is not loaded",
|
|
):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
data,
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "service_data"),
|
|
[
|
|
(GAS_SERVICE_NAME, {}),
|
|
(ENERGY_SERVICE_NAME, {}),
|
|
(ENERGY_SERVICE_NAME, {"price_type": "all_in", "interval": "quarter"}),
|
|
],
|
|
)
|
|
async def test_service_no_data_returns_validation_error(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
service_data: dict[str, str],
|
|
) -> None:
|
|
"""Test backend no-data errors are surfaced as service validation errors."""
|
|
method = (
|
|
mock_energyzero.get_gas_prices
|
|
if service == GAS_SERVICE_NAME
|
|
else mock_energyzero.get_electricity_prices
|
|
)
|
|
method.side_effect = EnergyZeroNoDataError(
|
|
"not found: prices do not span the whole requested date"
|
|
)
|
|
|
|
with pytest.raises(
|
|
ServiceValidationError,
|
|
match=r"No price data available for 2026-04-10\.?",
|
|
):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
**service_data,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("entity_interval", ["hourly", "quarter_hourly"])
|
|
@pytest.mark.parametrize(
|
|
("interval_data", "expected_interval"),
|
|
[
|
|
pytest.param({}, Interval.HOUR, id="default-hour"),
|
|
pytest.param({"interval": "hour"}, Interval.HOUR, id="hour"),
|
|
pytest.param({"interval": "quarter"}, Interval.QUARTER, id="quarter"),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("price_data", "incl_vat", "expected_price_type"),
|
|
[
|
|
pytest.param({}, True, PriceType.MARKET_WITH_VAT, id="default-vat"),
|
|
pytest.param({}, False, PriceType.MARKET, id="default-no-vat"),
|
|
pytest.param(
|
|
{"price_type": "market"}, True, PriceType.MARKET_WITH_VAT, id="market-vat"
|
|
),
|
|
pytest.param(
|
|
{"price_type": "market"}, False, PriceType.MARKET, id="market-no-vat"
|
|
),
|
|
pytest.param({"price_type": "all_in"}, True, PriceType.ALL_IN, id="all-in-vat"),
|
|
pytest.param(
|
|
{"price_type": "all_in"},
|
|
False,
|
|
PriceType.ALL_IN_EXCL_VAT,
|
|
id="all-in-no-vat",
|
|
),
|
|
],
|
|
)
|
|
async def test_energy_service_options(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
entity_interval: str,
|
|
interval_data: dict[str, str],
|
|
expected_interval: Interval,
|
|
price_data: dict[str, str],
|
|
incl_vat: bool,
|
|
expected_price_type: PriceType,
|
|
) -> None:
|
|
"""Action options and defaults are independent of entity configuration."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
hass.config_entries.async_update_entry(
|
|
mock_config_entry, options={CONF_ELECTRICITY_PRICE_INTERVAL: entity_interval}
|
|
)
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
coordinator = mock_config_entry.runtime_data
|
|
coordinator_data = coordinator.data
|
|
entity_states = hass.states.async_all()
|
|
mock_energyzero.reset_mock()
|
|
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
ENERGY_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": incl_vat,
|
|
**price_data,
|
|
**interval_data,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
mock_energyzero.get_electricity_prices.assert_awaited_once_with(
|
|
start_date=date(2026, 4, 10),
|
|
end_date=date(2026, 4, 10),
|
|
interval=expected_interval,
|
|
price_type=expected_price_type,
|
|
local_tz=ZoneInfo(hass.config.time_zone),
|
|
)
|
|
mock_energyzero.get_gas_prices.assert_not_awaited()
|
|
assert coordinator.data is coordinator_data
|
|
assert hass.states.async_all() == entity_states
|
|
assert mock_config_entry.options == {
|
|
CONF_ELECTRICITY_PRICE_INTERVAL: entity_interval
|
|
}
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("start", "end", "first_timestamp", "period_count"),
|
|
[
|
|
pytest.param(
|
|
"2026-04-10", "2026-04-10", "2026-04-09T22:00:00+00:00", 96, id="date-only"
|
|
),
|
|
pytest.param(
|
|
"2026-04-10 00:07:00",
|
|
"2026-04-10 00:38:00",
|
|
"2026-04-09T22:00:00+00:00",
|
|
3,
|
|
id="partial-periods",
|
|
),
|
|
pytest.param(
|
|
"2026-04-10 00:15:00",
|
|
"2026-04-10 00:15:00",
|
|
"2026-04-09T22:00:00+00:00",
|
|
96,
|
|
id="equal-times-full-day",
|
|
),
|
|
pytest.param(
|
|
"2026-04-10 00:15:00",
|
|
"2026-04-10 00:30:00",
|
|
"2026-04-09T22:15:00+00:00",
|
|
1,
|
|
id="exact-boundaries",
|
|
),
|
|
pytest.param(
|
|
"2026-04-10 23:53:00+02:00",
|
|
"2026-04-11 00:07:00+02:00",
|
|
"2026-04-10T21:45:00+00:00",
|
|
2,
|
|
id="multiple-days",
|
|
),
|
|
pytest.param(
|
|
"2026-03-29", "2026-03-29", "2026-03-28T23:00:00+00:00", 92, id="spring-dst"
|
|
),
|
|
pytest.param(
|
|
"2026-10-25",
|
|
"2026-10-25",
|
|
"2026-10-24T22:00:00+00:00",
|
|
100,
|
|
id="autumn-dst",
|
|
),
|
|
pytest.param(
|
|
"2026-03-29 01:53:00+01:00",
|
|
"2026-03-29 03:07:00+02:00",
|
|
"2026-03-29T00:45:00+00:00",
|
|
2,
|
|
id="spring-overlap",
|
|
),
|
|
pytest.param(
|
|
"2026-10-25 02:53:00+02:00",
|
|
"2026-10-25 02:07:00+01:00",
|
|
"2026-10-25T00:45:00+00:00",
|
|
2,
|
|
id="autumn-overlap",
|
|
),
|
|
],
|
|
)
|
|
async def test_energy_service_quarter_ranges(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
start: str,
|
|
end: str,
|
|
first_timestamp: str,
|
|
period_count: int,
|
|
) -> None:
|
|
"""Filter actual quarter-hour ranges, including partial periods and DST."""
|
|
await hass.config.async_set_time_zone("Europe/Amsterdam")
|
|
local_tz = ZoneInfo(hass.config.time_zone)
|
|
first_day = date.fromisoformat(start[:10])
|
|
last_day = date.fromisoformat(end[:10])
|
|
days = [
|
|
first_day + timedelta(days=index)
|
|
for index in range((last_day - first_day).days + 1)
|
|
]
|
|
step = timedelta(minutes=15)
|
|
datasets = []
|
|
for day in days:
|
|
day_start = datetime.combine(day, datetime.min.time(), local_tz).astimezone(UTC)
|
|
day_end = datetime.combine(
|
|
day + timedelta(days=1), datetime.min.time(), local_tz
|
|
).astimezone(UTC)
|
|
datasets.append(
|
|
EnergyPrices(
|
|
prices={
|
|
TimeRange(
|
|
day_start + index * step, day_start + (index + 1) * step
|
|
): 0.25
|
|
for index in range((day_end - day_start) // step)
|
|
},
|
|
average_price=0.25,
|
|
)
|
|
)
|
|
mock_energyzero.reset_mock()
|
|
mock_energyzero.get_electricity_prices.side_effect = datasets
|
|
|
|
response = await hass.services.async_call(
|
|
DOMAIN,
|
|
ENERGY_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
"price_type": "all_in",
|
|
"interval": "quarter",
|
|
"start": start,
|
|
"end": end,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
|
|
first = datetime.fromisoformat(first_timestamp)
|
|
assert response == {
|
|
"prices": [
|
|
{
|
|
"price": 0.25,
|
|
"timestamp": str(first + index * step),
|
|
"start": str(first + index * step),
|
|
"end": str(first + (index + 1) * step),
|
|
}
|
|
for index in range(period_count)
|
|
]
|
|
}
|
|
assert mock_energyzero.get_electricity_prices.await_args_list == [
|
|
call(
|
|
start_date=day,
|
|
end_date=day,
|
|
interval=Interval.QUARTER,
|
|
price_type=PriceType.ALL_IN,
|
|
local_tz=local_tz,
|
|
)
|
|
for day in days
|
|
]
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
@pytest.mark.parametrize(
|
|
("service", "service_data"),
|
|
[
|
|
(ENERGY_SERVICE_NAME, {"price_type": "market_with_vat"}),
|
|
(ENERGY_SERVICE_NAME, {"interval": "day"}),
|
|
(GAS_SERVICE_NAME, {"price_type": "all_in"}),
|
|
(GAS_SERVICE_NAME, {"interval": "quarter"}),
|
|
],
|
|
)
|
|
async def test_service_rejects_unsupported_options(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
service: str,
|
|
service_data: dict[str, str],
|
|
) -> None:
|
|
"""Only the electricity action accepts the supported new field values."""
|
|
mock_energyzero.reset_mock()
|
|
with pytest.raises(vol.Invalid):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
service,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
**service_data,
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
mock_energyzero.get_electricity_prices.assert_not_awaited()
|
|
mock_energyzero.get_gas_prices.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.usefixtures("init_integration")
|
|
async def test_energy_service_quarter_invalid_range(
|
|
hass: HomeAssistant,
|
|
mock_energyzero: AsyncMock,
|
|
mock_config_entry: MockConfigEntry,
|
|
) -> None:
|
|
"""Reject reversed quarter-hour ranges before calling the API."""
|
|
mock_energyzero.reset_mock()
|
|
with pytest.raises(ServiceValidationError, match="Invalid date range provided"):
|
|
await hass.services.async_call(
|
|
DOMAIN,
|
|
ENERGY_SERVICE_NAME,
|
|
{
|
|
ATTR_CONFIG_ENTRY: mock_config_entry.entry_id,
|
|
"incl_vat": True,
|
|
"price_type": "all_in",
|
|
"interval": "quarter",
|
|
"start": "2026-04-10 00:15:00",
|
|
"end": "2026-04-10 00:10:00",
|
|
},
|
|
blocking=True,
|
|
return_response=True,
|
|
)
|
|
mock_energyzero.get_electricity_prices.assert_not_awaited()
|