mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Implement a new service that progressively relaxes user-defined filters to ensure a result is always returned when price data is available. This includes three phases: halving the minimum distance from average, expanding level filters, and reducing duration. Impact: Users will receive results even when strict filters would otherwise yield no matches, improving the reliability of scheduling actions. feat(pricing): enhance scheduling actions with new parameters Introduce new parameters `smooth_outliers`, `min_distance_from_avg`, and `allow_relaxation` to scheduling actions, allowing for better control over price selection and ensuring results are meaningfully different from average prices. Impact: Users can now fine-tune their scheduling actions to avoid marginal savings and ensure more uniform pricing within selected windows. docs(scheduling): update documentation for new features Revise the scheduling actions documentation to include new parameters and their effects, such as outlier smoothing and minimum distance from average, along with examples for better user understanding. Impact: Users will have clearer guidance on how to utilize new features effectively in their automations. test(scheduling): add tests for new relaxation logic Implement unit tests to verify the behavior of the new relaxation logic in scheduling actions, ensuring that filters are correctly relaxed and results are returned as expected. Impact: Increased test coverage and reliability of the scheduling features.
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""Tests for find service response contracts: reason codes and schedule comparison details."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.core import ServiceCall
|
|
|
|
import pytest
|
|
|
|
from custom_components.tibber_prices.services import (
|
|
find_cheapest_block as block_module,
|
|
find_cheapest_hours as hours_module,
|
|
find_cheapest_schedule as schedule_module,
|
|
)
|
|
from custom_components.tibber_prices.services.find_cheapest_block import (
|
|
_determine_no_window_reason,
|
|
handle_find_cheapest_block,
|
|
)
|
|
from custom_components.tibber_prices.services.find_cheapest_hours import (
|
|
_determine_no_intervals_reason,
|
|
handle_find_cheapest_hours,
|
|
)
|
|
from custom_components.tibber_prices.services.find_cheapest_schedule import (
|
|
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
|
|
_compute_task_price_comparison,
|
|
_determine_schedule_reason,
|
|
)
|
|
|
|
|
|
def _make_intervals(prices: list[float], start: datetime | None = None) -> list[dict]:
|
|
"""Create quarter-hour intervals for tests."""
|
|
base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
|
|
return [
|
|
{
|
|
"startsAt": (base + timedelta(minutes=15 * i)).isoformat(),
|
|
"total": price,
|
|
"level": "NORMAL",
|
|
}
|
|
for i, price in enumerate(prices)
|
|
]
|
|
|
|
|
|
class TestBlockNoResultReasons:
|
|
"""Reason classification for contiguous block service."""
|
|
|
|
def test_reason_no_data(self) -> None:
|
|
"""Return no_data_in_range when no intervals exist."""
|
|
reason = _determine_no_window_reason([], [], 4, level_filter_active=False)
|
|
assert reason == "no_data_in_range"
|
|
|
|
def test_reason_level_filter_eliminated_all(self) -> None:
|
|
"""Return level-filter reason when filters remove all intervals."""
|
|
reason = _determine_no_window_reason(
|
|
_make_intervals([10.0, 12.0]),
|
|
[],
|
|
4,
|
|
level_filter_active=True,
|
|
)
|
|
assert reason == "no_intervals_matching_level_filter"
|
|
|
|
def test_reason_not_enough_intervals_after_filter(self) -> None:
|
|
"""Return insufficient_intervals_after_filter for short filtered pool."""
|
|
reason = _determine_no_window_reason(
|
|
_make_intervals([10.0, 12.0, 13.0]),
|
|
_make_intervals([10.0, 12.0]),
|
|
4,
|
|
level_filter_active=False,
|
|
)
|
|
assert reason == "insufficient_intervals_after_filter"
|
|
|
|
|
|
class TestHoursNoResultReasons:
|
|
"""Reason classification for cheapest/most-expensive hours service."""
|
|
|
|
def test_reason_no_data(self) -> None:
|
|
"""Return no_data_in_range when interval pool is empty."""
|
|
reason = _determine_no_intervals_reason([], [], 6, level_filter_active=False)
|
|
assert reason == "no_data_in_range"
|
|
|
|
def test_reason_level_filter_eliminated_all(self) -> None:
|
|
"""Return level-filter reason when all intervals are filtered out."""
|
|
reason = _determine_no_intervals_reason(
|
|
_make_intervals([10.0, 11.0]),
|
|
[],
|
|
4,
|
|
level_filter_active=True,
|
|
)
|
|
assert reason == "no_intervals_matching_level_filter"
|
|
|
|
def test_reason_not_enough_intervals_after_filter(self) -> None:
|
|
"""Return insufficient_intervals_after_filter when pool is too short."""
|
|
reason = _determine_no_intervals_reason(
|
|
_make_intervals([10.0, 11.0, 12.0]),
|
|
_make_intervals([10.0, 11.0]),
|
|
4,
|
|
level_filter_active=False,
|
|
)
|
|
assert reason == "insufficient_intervals_after_filter"
|
|
|
|
|
|
class TestScheduleReasonAndComparison:
|
|
"""Schedule service reason codes and comparison details behavior."""
|
|
|
|
def test_schedule_reason_no_data(self) -> None:
|
|
"""Return no_data_in_range when schedule has no source intervals."""
|
|
reason = _determine_schedule_reason(
|
|
all_tasks_scheduled=False,
|
|
assignments_count=0,
|
|
price_info=[],
|
|
filtered_price_info=[],
|
|
level_filter_active=False,
|
|
)
|
|
assert reason == "no_data_in_range"
|
|
|
|
def test_schedule_reason_level_filter(self) -> None:
|
|
"""Return level-filter reason when filter removes all schedule candidates."""
|
|
reason = _determine_schedule_reason(
|
|
all_tasks_scheduled=False,
|
|
assignments_count=0,
|
|
price_info=_make_intervals([10.0, 20.0]),
|
|
filtered_price_info=[],
|
|
level_filter_active=True,
|
|
)
|
|
assert reason == "no_intervals_matching_level_filter"
|
|
|
|
def test_schedule_reason_partial(self) -> None:
|
|
"""Return partial-schedule reason when some tasks remain unscheduled."""
|
|
reason = _determine_schedule_reason(
|
|
all_tasks_scheduled=False,
|
|
assignments_count=1,
|
|
price_info=_make_intervals([10.0, 20.0, 30.0]),
|
|
filtered_price_info=_make_intervals([10.0, 20.0, 30.0]),
|
|
level_filter_active=False,
|
|
)
|
|
assert reason == "insufficient_contiguous_window_for_some_tasks"
|
|
|
|
def test_schedule_schema_accepts_include_comparison_details(self) -> None:
|
|
"""Schedule schema should accept include_comparison_details flag."""
|
|
result = cast(
|
|
"dict[str, Any]",
|
|
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
|
|
{
|
|
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=2)}],
|
|
"include_comparison_details": True,
|
|
}
|
|
),
|
|
)
|
|
assert result["include_comparison_details"] is True
|
|
|
|
def test_task_comparison_includes_details(self) -> None:
|
|
"""Task comparison helper should emit detail fields when enabled."""
|
|
full_intervals = _make_intervals([5.0, 10.0, 50.0, 60.0])
|
|
task_intervals = full_intervals[:2]
|
|
|
|
comparison = _compute_task_price_comparison(
|
|
task_intervals,
|
|
full_intervals,
|
|
1,
|
|
include_details=True,
|
|
)
|
|
|
|
assert comparison is not None
|
|
assert "comparison_price_mean" in comparison
|
|
assert "comparison_price_min" in comparison
|
|
assert "comparison_price_max" in comparison
|
|
assert "comparison_window_start" in comparison
|
|
assert "comparison_window_end" in comparison
|
|
|
|
|
|
class _FakePool:
|
|
"""Minimal async interval pool for service handler tests."""
|
|
|
|
def __init__(self, intervals: list[dict]) -> None:
|
|
"""Store static interval list returned by get_intervals."""
|
|
self._intervals = intervals
|
|
|
|
async def get_intervals(self, **_kwargs: object) -> tuple[list[dict], bool]:
|
|
"""Return predefined intervals and no API-call marker."""
|
|
return self._intervals, False
|
|
|
|
|
|
def _build_fake_entry_and_coordinator(intervals: list[dict]) -> tuple[SimpleNamespace, SimpleNamespace, dict]:
|
|
"""Build a minimal entry/coordinator/data tuple used by service handlers."""
|
|
pool = _FakePool(intervals)
|
|
entry = SimpleNamespace(
|
|
data={"home_id": "home_1", "currency": "EUR"},
|
|
runtime_data=SimpleNamespace(interval_pool=pool),
|
|
)
|
|
coordinator = SimpleNamespace(
|
|
api=object(),
|
|
_cached_user_data={"viewer": {"homes": [{"id": "home_1", "timeZone": "UTC"}]}},
|
|
)
|
|
data = {"priceInfo": intervals}
|
|
return entry, coordinator, data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_block_handler_returns_level_filter_reason(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Block handler should return reason when level filter eliminates all intervals."""
|
|
intervals = _make_intervals([10.0, 11.0, 12.0, 13.0])
|
|
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
|
|
|
monkeypatch.setattr(block_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
|
|
monkeypatch.setattr(block_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
|
|
monkeypatch.setattr(
|
|
block_module,
|
|
"resolve_search_range",
|
|
lambda _call_data, _now, _home_tz: (
|
|
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
|
|
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
|
|
),
|
|
)
|
|
|
|
call = SimpleNamespace(
|
|
hass=object(),
|
|
data={
|
|
"duration": timedelta(hours=1),
|
|
"max_price_level": "very_cheap",
|
|
"use_base_unit": True,
|
|
"allow_relaxation": False,
|
|
},
|
|
)
|
|
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
|
|
|
|
assert response["window_found"] is False
|
|
assert response["reason"] == "no_intervals_matching_level_filter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hours_handler_returns_insufficient_intervals_reason(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Hours handler should return insufficient_intervals_after_filter when pool is too short."""
|
|
intervals = _make_intervals([10.0, 11.0, 12.0]) # 3 intervals only
|
|
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
|
|
|
monkeypatch.setattr(hours_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
|
|
monkeypatch.setattr(hours_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
|
|
monkeypatch.setattr(
|
|
hours_module,
|
|
"resolve_search_range",
|
|
lambda _call_data, _now, _home_tz: (
|
|
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
|
|
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
|
|
),
|
|
)
|
|
|
|
call = SimpleNamespace(
|
|
hass=object(),
|
|
data={
|
|
"duration": timedelta(hours=1), # needs 4 intervals
|
|
"use_base_unit": True,
|
|
"allow_relaxation": False,
|
|
},
|
|
)
|
|
response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call)))
|
|
|
|
assert response["intervals_found"] is False
|
|
assert response["reason"] == "insufficient_intervals_after_filter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schedule_handler_adds_per_task_comparison_details(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Schedule handler should include per-task comparison details when requested."""
|
|
intervals = _make_intervals([5.0, 6.0, 50.0, 60.0])
|
|
fake_tuple = _build_fake_entry_and_coordinator(intervals)
|
|
|
|
monkeypatch.setattr(schedule_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
|
|
monkeypatch.setattr(schedule_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
|
|
monkeypatch.setattr(
|
|
schedule_module,
|
|
"resolve_search_range",
|
|
lambda _call_data, _now, _home_tz: (
|
|
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
|
|
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
|
|
),
|
|
)
|
|
|
|
call = SimpleNamespace(
|
|
hass=object(),
|
|
data={
|
|
"tasks": [{"name": "dishwasher", "duration": timedelta(minutes=30)}],
|
|
"include_comparison_details": True,
|
|
"use_base_unit": True,
|
|
},
|
|
)
|
|
response = cast("dict[str, Any]", await schedule_module.handle_find_cheapest_schedule(cast("ServiceCall", call)))
|
|
|
|
assert response["all_tasks_scheduled"] is True
|
|
assert response["reason"] is None
|
|
tasks = cast("list[dict[str, Any]]", response["tasks"])
|
|
assert len(tasks) == 1
|
|
comparison = cast("dict[str, Any] | None", tasks[0]["price_comparison"])
|
|
assert comparison is not None
|
|
assert "comparison_price_min" in comparison
|
|
assert "comparison_price_max" in comparison
|
|
assert "comparison_window_end" in comparison
|