mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
New services for finding optimal electricity price windows: - find_cheapest_block: Cheapest contiguous time block (e.g., dishwasher) - find_cheapest_hours: Cheapest N hours, non-contiguous (e.g., EV charging) - find_cheapest_schedule: Multi-task scheduling with no-overlap (e.g., shared circuit) - find_most_expensive_block: Most expensive contiguous block (peak avoidance) - find_most_expensive_hours: Most expensive N hours (consumption shifting) Key features: - Flexible search range (today, tomorrow, today+tomorrow, rolling window) - Power profile support for variable consumption patterns - Price level filtering (e.g., only CHEAP/VERY_CHEAP intervals) - Comparison details showing savings vs. alternatives - Sliding window algorithm (O(n)) for block search, greedy scheduling for multi-task optimization Also includes: - Shared validation utilities (search range, price level, power profile) - entry_id now optional on all services (auto-selects single home) - Input validation for existing services (time range, filter conflicts) - Service icons for all new and existing services - Translations for all 5 languages (en, de, nb, nl, sv) - Removed 10 unused config.error translation keys (replaced by exceptions) - Tests for price window algorithms and search range resolution Impact: Users can find optimal time windows for appliances, EV charging, and multi-device scheduling via HA service calls. Existing services improved with optional entry_id and better input validation.
258 lines
9.3 KiB
Python
258 lines
9.3 KiB
Python
"""
|
|
Tests for resolve_search_range helper and negative offset support.
|
|
|
|
Verifies that services can search into the past using:
|
|
- Negative search_start_day_offset / search_end_day_offset
|
|
- Negative search_start_offset_minutes / search_end_offset_minutes
|
|
- Explicit past search_start / search_end datetimes
|
|
|
|
Also validates schema boundaries for all 4 services.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from datetime import time as dt_time
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
import voluptuous as vol
|
|
|
|
from custom_components.tibber_prices.services.find_cheapest_block import (
|
|
_COMMON_BLOCK_SCHEMA,
|
|
)
|
|
from custom_components.tibber_prices.services.find_cheapest_hours import (
|
|
_COMMON_HOURS_SCHEMA,
|
|
)
|
|
from custom_components.tibber_prices.services.helpers import (
|
|
resolve_search_range,
|
|
)
|
|
|
|
BERLIN = ZoneInfo("Europe/Berlin")
|
|
|
|
|
|
# =============================================================================
|
|
# resolve_search_range: Negative day offsets
|
|
# =============================================================================
|
|
|
|
|
|
class TestResolveSearchRangeNegativeDayOffset:
|
|
"""Test that negative day offsets correctly resolve to past dates."""
|
|
|
|
def test_negative_start_day_offset(self) -> None:
|
|
"""Start yesterday at 06:00."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_time": dt_time(6, 0, 0),
|
|
"search_start_day_offset": -1,
|
|
}
|
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
|
# Should be yesterday 06:00
|
|
assert start.day == 10
|
|
assert start.hour == 6
|
|
assert start.minute == 0
|
|
|
|
def test_negative_both_day_offsets(self) -> None:
|
|
"""Full day in the past: yesterday 00:00 to yesterday 23:59."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_time": dt_time(0, 0, 0),
|
|
"search_start_day_offset": -1,
|
|
"search_end_time": dt_time(23, 59, 0),
|
|
"search_end_day_offset": -1,
|
|
}
|
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
|
assert start.day == 10
|
|
assert start.hour == 0
|
|
assert end.day == 10
|
|
assert end.hour == 23
|
|
|
|
def test_negative_7_day_offset(self) -> None:
|
|
"""Start 7 days ago."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_time": dt_time(0, 0, 0),
|
|
"search_start_day_offset": -7,
|
|
"search_end_time": dt_time(23, 59, 0),
|
|
"search_end_day_offset": -7,
|
|
}
|
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
|
assert start.day == 4
|
|
assert end.day == 4
|
|
|
|
def test_cross_day_range_past_to_today(self) -> None:
|
|
"""Start yesterday, end today."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_time": dt_time(18, 0, 0),
|
|
"search_start_day_offset": -1,
|
|
"search_end_time": dt_time(6, 0, 0),
|
|
"search_end_day_offset": 0,
|
|
}
|
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
|
assert start.day == 10
|
|
assert start.hour == 18
|
|
assert end.day == 11
|
|
assert end.hour == 6
|
|
|
|
|
|
# =============================================================================
|
|
# resolve_search_range: Negative offset minutes
|
|
# =============================================================================
|
|
|
|
|
|
class TestResolveSearchRangeNegativeOffsetMinutes:
|
|
"""Test that negative offset minutes correctly resolve to past times."""
|
|
|
|
def test_negative_start_offset(self) -> None:
|
|
"""Start 2 hours ago."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_offset_minutes": -120,
|
|
"include_current_interval": True,
|
|
}
|
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
|
# -120 min from 14:30 = 12:30, floored to 12:30
|
|
assert start.hour == 12
|
|
assert start.minute == 30
|
|
|
|
def test_negative_start_offset_floors_to_quarter(self) -> None:
|
|
"""Negative offset gets floored to quarter-hour boundary."""
|
|
now = datetime(2026, 4, 11, 14, 37, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_offset_minutes": -60,
|
|
"include_current_interval": True,
|
|
}
|
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
|
# -60 min from 14:37 = 13:37, floored to 13:30
|
|
assert start.hour == 13
|
|
assert start.minute == 30
|
|
|
|
def test_negative_end_offset(self) -> None:
|
|
"""End 1 hour ago (fully historical range)."""
|
|
now = datetime(2026, 4, 11, 14, 30, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_offset_minutes": -180,
|
|
"search_end_offset_minutes": -60,
|
|
"include_current_interval": True,
|
|
}
|
|
start, end = resolve_search_range(call_data, now, BERLIN)
|
|
# Start: -180 min → 11:30, End: -60 min → 13:30
|
|
assert start.hour == 11
|
|
assert start.minute == 30
|
|
assert end.hour == 13
|
|
assert end.minute == 30
|
|
|
|
def test_large_negative_offset_crosses_day(self) -> None:
|
|
"""Large negative offset crosses day boundary."""
|
|
now = datetime(2026, 4, 11, 2, 0, tzinfo=BERLIN)
|
|
call_data = {
|
|
"search_start_offset_minutes": -180,
|
|
"include_current_interval": True,
|
|
}
|
|
start, _end = resolve_search_range(call_data, now, BERLIN)
|
|
# -180 min from 02:00 = 23:00 yesterday
|
|
assert start.day == 10
|
|
assert start.hour == 23
|
|
|
|
|
|
# =============================================================================
|
|
# Schema validation: day_offset boundaries
|
|
# =============================================================================
|
|
|
|
|
|
class TestSchemaValidation:
|
|
"""Verify that schemas accept negative offsets within bounds."""
|
|
|
|
def _validate_block_schema(self, data: dict) -> dict:
|
|
"""Validate data through block schema."""
|
|
schema = vol.Schema(_COMMON_BLOCK_SCHEMA)
|
|
return schema(data)
|
|
|
|
def _validate_hours_schema(self, data: dict) -> dict:
|
|
"""Validate data through hours schema."""
|
|
schema = vol.Schema(_COMMON_HOURS_SCHEMA)
|
|
return schema(data)
|
|
|
|
def test_block_schema_accepts_negative_day_offset(self) -> None:
|
|
"""Block schema allows negative day offsets."""
|
|
result = self._validate_block_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=1),
|
|
"search_start_day_offset": -3,
|
|
"search_end_day_offset": -1,
|
|
}
|
|
)
|
|
assert result["search_start_day_offset"] == -3
|
|
assert result["search_end_day_offset"] == -1
|
|
|
|
def test_block_schema_accepts_negative_offset_minutes(self) -> None:
|
|
"""Block schema allows negative offset minutes."""
|
|
result = self._validate_block_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=1),
|
|
"search_start_offset_minutes": -1440,
|
|
"search_end_offset_minutes": -60,
|
|
}
|
|
)
|
|
assert result["search_start_offset_minutes"] == -1440
|
|
assert result["search_end_offset_minutes"] == -60
|
|
|
|
def test_block_schema_rejects_out_of_bounds_day_offset(self) -> None:
|
|
"""Block schema rejects day offset < -7."""
|
|
with pytest.raises(vol.Invalid):
|
|
self._validate_block_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=1),
|
|
"search_start_day_offset": -8,
|
|
}
|
|
)
|
|
|
|
def test_block_schema_max_day_offset_still_2(self) -> None:
|
|
"""Block schema still limits forward to +2."""
|
|
with pytest.raises(vol.Invalid):
|
|
self._validate_block_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=1),
|
|
"search_start_day_offset": 3,
|
|
}
|
|
)
|
|
|
|
def test_hours_schema_accepts_negative_day_offset(self) -> None:
|
|
"""Hours schema allows negative day offsets."""
|
|
result = self._validate_hours_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=2),
|
|
"search_start_day_offset": -7,
|
|
"search_end_day_offset": -5,
|
|
}
|
|
)
|
|
assert result["search_start_day_offset"] == -7
|
|
|
|
def test_hours_schema_accepts_negative_offset_minutes(self) -> None:
|
|
"""Hours schema allows negative offset minutes."""
|
|
result = self._validate_hours_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=2),
|
|
"search_start_offset_minutes": -10080,
|
|
"search_end_offset_minutes": -60,
|
|
}
|
|
)
|
|
assert result["search_start_offset_minutes"] == -10080
|
|
|
|
def test_hours_schema_rejects_out_of_bounds_offset_minutes(self) -> None:
|
|
"""Hours schema rejects offset minutes outside ±10080."""
|
|
with pytest.raises(vol.Invalid):
|
|
self._validate_hours_schema(
|
|
{
|
|
"entry_id": "test",
|
|
"duration": timedelta(hours=2),
|
|
"search_start_offset_minutes": -10081,
|
|
}
|
|
)
|