mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 13:23:41 +00:00
Changed from centralized main+subentry coordinator pattern to independent
coordinators per home. Each config entry now manages its own home data
with its own API client and access token.
Architecture changes:
- API Client: async_get_price_info() changed from home_ids: set[str] to home_id: str
* Removed GraphQL alias pattern (home0, home1, ...)
* Single-home query structure without aliasing
* Simplified response parsing (viewer.home instead of viewer.home0)
- Coordinator: Removed main/subentry distinction
* Deleted is_main_entry() and _has_existing_main_coordinator()
* Each coordinator fetches its own data independently
* Removed _find_main_coordinator() and _get_configured_home_ids()
* Simplified _async_update_data() - no subentry logic
* Added _home_id instance variable from config_entry.data
- __init__.py: New _get_access_token() helper
* Handles token retrieval for both parent and subentries
* Subentries find parent entry to get shared access token
* Creates single API client instance per coordinator
- Data structures: Flat single-home format
* Old: {"homes": {home_id: {"price_info": [...]}}}
* New: {"home_id": str, "price_info": [...], "currency": str}
* Attribute name: "periods" → "pricePeriods" (consistent with priceInfo)
- helpers.py: Removed get_configured_home_ids() (no longer needed)
* parse_all_timestamps() updated for single-home structure
Impact: Each home operates independently with its own lifecycle tracking,
caching, and period calculations. Simpler architecture, easier debugging,
better isolation between homes.
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""
|
|
End-to-End Tests for Peak Price Period Generation (Nov 2025 Bug Fix).
|
|
|
|
These tests validate that the sign convention bug fix works correctly:
|
|
- Bug: Negative flex (-20%) wasn't normalized → 100% FLEX filtering
|
|
- Fix: abs() normalization in periods.py + removed redundant condition
|
|
|
|
Test coverage matches manual testing checklist:
|
|
1. ✅ Peak periods generate (not 0)
|
|
2. ✅ FLEX filter stats reasonable (~40-50%, not 100%)
|
|
3. ✅ Relaxation succeeds at reasonable flex (not maxed at 50%)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from custom_components.tibber_prices.coordinator.period_handlers import (
|
|
TibberPricesPeriodConfig,
|
|
calculate_periods_with_relaxation,
|
|
)
|
|
from custom_components.tibber_prices.coordinator.time_service import (
|
|
TibberPricesTimeService,
|
|
)
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
|
|
def _create_realistic_intervals() -> list[dict]:
|
|
"""
|
|
Create realistic test data matching German market Nov 22, 2025.
|
|
|
|
Pattern: Morning peak (6-9h), midday low (9-15h), evening moderate (15-24h).
|
|
Daily stats: Min=30.44ct, Avg=33.26ct, Max=36.03ct
|
|
"""
|
|
# Use CURRENT date so tests work regardless of when they run
|
|
now_local = dt_util.now()
|
|
base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
daily_min, daily_avg, daily_max = 0.3044, 0.3326, 0.3603
|
|
|
|
def _create_interval(hour: int, minute: int, price: float, level: str, rating: str) -> dict:
|
|
"""Create a single interval dict."""
|
|
return {
|
|
"startsAt": base_time.replace(hour=hour, minute=minute), # datetime object
|
|
"total": price,
|
|
"level": level,
|
|
"rating_level": rating,
|
|
"_original_price": price,
|
|
"trailing_avg_24h": daily_avg,
|
|
"daily_min": daily_min,
|
|
"daily_avg": daily_avg,
|
|
"daily_max": daily_max,
|
|
}
|
|
|
|
# Build all intervals as list comprehensions
|
|
intervals = []
|
|
|
|
# Overnight (00:00-06:00) - NORMAL
|
|
intervals.extend(
|
|
[_create_interval(hour, minute, 0.318, "NORMAL", "NORMAL") for hour in range(6) for minute in [0, 15, 30, 45]]
|
|
)
|
|
|
|
# Morning spike (06:00-09:00) - EXPENSIVE
|
|
intervals.extend(
|
|
[
|
|
_create_interval(
|
|
hour,
|
|
minute,
|
|
price := 0.33 + (hour - 6) * 0.01,
|
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
|
"HIGH" if price > 0.35 else "NORMAL",
|
|
)
|
|
for hour in range(6, 9)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
# Midday low (09:00-15:00) - CHEAP
|
|
intervals.extend(
|
|
[
|
|
_create_interval(hour, minute, 0.305 + (hour - 12) * 0.002, "CHEAP", "LOW")
|
|
for hour in range(9, 15)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
# Evening moderate (15:00-24:00) - NORMAL to EXPENSIVE
|
|
intervals.extend(
|
|
[
|
|
_create_interval(
|
|
hour,
|
|
minute,
|
|
price := 0.32 + (hour - 15) * 0.005,
|
|
"EXPENSIVE" if price > 0.34 else "NORMAL",
|
|
"HIGH" if price > 0.35 else "NORMAL",
|
|
)
|
|
for hour in range(15, 24)
|
|
for minute in [0, 15, 30, 45]
|
|
]
|
|
)
|
|
|
|
return intervals
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
|
|
class TestPeakPriceGenerationWorks:
|
|
"""Validate that peak price periods generate successfully after bug fix."""
|
|
|
|
def test_peak_periods_generate_successfully(self) -> None:
|
|
"""
|
|
✅ PRIMARY TEST: Peak periods generate (not 0 like the bug).
|
|
|
|
Bug: 192/192 intervals filtered by FLEX (100%) → 0 periods
|
|
Fix: Negative flex normalized → periods generate
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
# Mock coordinator (minimal setup)
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Create config with normalized positive flex (simulating fix)
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20, # 20% positive (after abs() normalization)
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True, # Peak price mode
|
|
)
|
|
|
|
# Calculate periods with relaxation
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True, # Allow all levels
|
|
time=time_service,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
# Bug validation: periods found (not 0)
|
|
assert len(periods) > 0, "Peak periods should generate after bug fix"
|
|
assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}"
|
|
|
|
def test_negative_flex_normalization_effect(self) -> None:
|
|
"""
|
|
✅ TEST: Positive flex (normalized) produces periods.
|
|
|
|
Bug: Would use negative flex (-20%) directly in math → 100% FLEX filter
|
|
Fix: abs() ensures positive flex → reasonable filtering
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Test with positive flex (simulates normalized result)
|
|
config_positive = TibberPricesPeriodConfig(
|
|
flex=0.20, # Positive after normalization
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result_pos = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config_positive,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
)
|
|
|
|
periods_pos = result_pos.get("periods", [])
|
|
|
|
# With normalized positive flex, should find periods
|
|
assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}"
|
|
|
|
def test_periods_contain_high_prices(self) -> None:
|
|
"""
|
|
✅ TEST: Peak periods contain high prices (not cheap ones).
|
|
|
|
Validates periods include expensive intervals, not cheap ones.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
daily_min = intervals[0]["daily_min"]
|
|
|
|
# Check period averages are NOT near daily minimum
|
|
for period in periods:
|
|
period_avg = period.get("price_avg", 0)
|
|
assert period_avg > daily_min * 1.05, (
|
|
f"Peak period has too low avg: {period_avg:.4f} vs daily_min={daily_min:.4f}"
|
|
)
|
|
|
|
def test_relaxation_works_at_reasonable_flex(self) -> None:
|
|
"""
|
|
✅ TEST: Relaxation succeeds without maxing flex at 50%.
|
|
|
|
Validates relaxation finds periods at reasonable flex levels.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
# Lower flex to trigger relaxation
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.15, # 15% - may need relaxation
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
# Should find periods via relaxation
|
|
assert len(periods) >= 2, "Relaxation should find periods"
|
|
|
|
# Check if relaxation was used
|
|
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
|
if "max_flex_used" in relaxation_meta:
|
|
max_flex_used = relaxation_meta["max_flex_used"]
|
|
# Bug would need ~50% flex
|
|
# Fix: reasonable flex (15-35%) is sufficient
|
|
assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%"
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
|
|
class TestBugRegressionValidation:
|
|
"""Regression tests for the Nov 2025 sign convention bug."""
|
|
|
|
def test_metadata_shows_reasonable_flex_used(self) -> None:
|
|
"""
|
|
✅ REGRESSION: Metadata shows flex used was reasonable (not 50%).
|
|
|
|
This indirectly validates FLEX filter didn't block everything.
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
)
|
|
|
|
# Check metadata from result
|
|
metadata = result.get("metadata", {})
|
|
config_used = metadata.get("config", {})
|
|
|
|
if "flex" in config_used:
|
|
flex_used = config_used["flex"]
|
|
# Bug would need ~50% flex to find anything
|
|
# Fix: reasonable flex (~20-30%) is sufficient
|
|
assert 0.15 <= flex_used <= 0.35, (
|
|
f"Expected flex 15-35%, got {flex_used * 100:.1f}% (Bug would require near 50%)"
|
|
)
|
|
|
|
# Also check relaxation metadata
|
|
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
|
|
if "max_flex_used" in relaxation_meta:
|
|
max_flex = relaxation_meta["max_flex_used"]
|
|
assert max_flex <= 0.35, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
|
|
|
|
def test_periods_include_expensive_intervals(self) -> None:
|
|
"""
|
|
✅ REGRESSION: Peak periods include intervals near daily max.
|
|
|
|
Bug had redundant condition: price >= ref AND price <= ref
|
|
Fix: Removed redundant condition → high prices included
|
|
"""
|
|
intervals = _create_realistic_intervals()
|
|
|
|
mock_coordinator = Mock()
|
|
mock_coordinator.config_entry = Mock()
|
|
time_service = TibberPricesTimeService(mock_coordinator)
|
|
# Mock now() to return test date
|
|
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
|
|
time_service.now = Mock(return_value=test_time)
|
|
|
|
config = TibberPricesPeriodConfig(
|
|
flex=0.20,
|
|
min_distance_from_avg=5.0,
|
|
min_period_length=30,
|
|
reverse_sort=True,
|
|
)
|
|
|
|
result = calculate_periods_with_relaxation(
|
|
intervals,
|
|
config=config,
|
|
enable_relaxation=True,
|
|
min_periods=2,
|
|
max_relaxation_attempts=11,
|
|
should_show_callback=lambda _: True,
|
|
time=time_service,
|
|
)
|
|
|
|
periods = result.get("periods", [])
|
|
|
|
daily_avg = intervals[0]["daily_avg"]
|
|
daily_max = intervals[0]["daily_max"]
|
|
|
|
# At least one period should have high average
|
|
max_period_avg = max(p.get("price_avg", 0) for p in periods)
|
|
|
|
assert max_period_avg >= daily_avg * 1.05, (
|
|
f"Peak periods should have high avg: {max_period_avg:.4f} vs daily_avg={daily_avg:.4f}"
|
|
)
|
|
|
|
# Check proximity to daily max
|
|
assert max_period_avg >= daily_max * 0.85, (
|
|
f"At least one period near daily_max: {max_period_avg:.4f} vs daily_max={daily_max:.4f}"
|
|
)
|