hass.tibber_prices/tests/test_mean_median_display.py
Julian Pawlowski aff3350de7 test(sensors): add comprehensive test coverage for mean/median display
Added new test suite and updated existing tests to verify always-both-attributes
behavior.

Changes:
- test_mean_median_display.py: NEW - Tests both attributes always present,
  configurable state display, recorder exclusion, and config changes
- test_avg_none_fallback.py: Updated to test mean/median individually (65 lines)
- test_sensor_timer_assignment.py: Minor updates for compatibility (12 lines)

Coverage: All 399 tests passing, including new edge cases for attribute
presence and recorder integration.
2025-12-18 15:14:22 +00:00

290 lines
10 KiB
Python

"""
Test mean/median display configuration for average sensors.
This test verifies that:
1. Sensors with average values respect CONF_AVERAGE_SENSOR_DISPLAY setting
2. State shows the configured value (mean or median)
3. Attributes show the alternate value
4. Calculations that depend on averages use mean internally (not affected by display setting)
"""
import statistics
from datetime import UTC, datetime, timedelta
from unittest.mock import Mock
import pytest
from custom_components.tibber_prices.const import (
CONF_AVERAGE_SENSOR_DISPLAY,
DEFAULT_AVERAGE_SENSOR_DISPLAY,
)
from custom_components.tibber_prices.sensor.attributes.helpers import (
add_alternate_average_attribute,
)
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median
@pytest.fixture
def mock_prices() -> list[dict]:
"""Create mock price data with known mean and median."""
base_time = datetime(2025, 12, 18, 0, 0, tzinfo=UTC)
# Prices: 10, 20, 30, 40, 100
# Mean = 40.0, Median = 30.0 (intentionally different)
return [
{"startsAt": (base_time + timedelta(hours=i)).isoformat(), "total": price, "level": "normal"}
for i, price in enumerate([10.0, 20.0, 30.0, 40.0, 100.0])
]
def test_average_price_today_displays_median_when_configured(
mock_prices: list[dict],
) -> None:
"""Test that average_price_today sensor shows median in state when configured."""
# Setup mock config entry with median display
mock_entry = Mock()
mock_entry.entry_id = "test_entry"
mock_entry.options = {
CONF_AVERAGE_SENSOR_DISPLAY: "median",
}
# Setup mock coordinator data with today's prices
coordinator_data = {
"priceInfo": mock_prices,
"currency": "EUR",
}
# Mock coordinator
mock_coordinator = Mock()
mock_coordinator.data = coordinator_data
mock_coordinator.config_entry = mock_entry
mock_coordinator.time = Mock()
mock_coordinator.time.now.return_value = datetime(2025, 12, 18, 12, 0, tzinfo=UTC)
# Get prices for today
prices = [float(p["total"]) for p in mock_prices]
# Calculate expected values
expected_mean = calculate_mean(prices) # 40.0
expected_median = calculate_median(prices) # 30.0
# Verify that mean and median are actually different (test setup)
assert expected_mean != expected_median, "Test setup requires different mean and median"
assert expected_mean == pytest.approx(40.0), f"Expected mean 40.0, got {expected_mean}"
assert expected_median == pytest.approx(30.0), f"Expected median 30.0, got {expected_median}"
def test_average_price_today_displays_mean_when_configured(
mock_prices: list[dict],
) -> None:
"""Test that average_price_today sensor shows mean in state when configured."""
# Setup mock config entry with mean display
mock_entry = Mock()
mock_entry.entry_id = "test_entry"
mock_entry.options = {
CONF_AVERAGE_SENSOR_DISPLAY: "mean",
}
# Setup mock coordinator data
coordinator_data = {
"priceInfo": mock_prices,
"currency": "EUR",
}
mock_coordinator = Mock()
mock_coordinator.data = coordinator_data
mock_coordinator.config_entry = mock_entry
mock_coordinator.time = Mock()
mock_coordinator.time.now.return_value = datetime(2025, 12, 18, 12, 0, tzinfo=UTC)
# Get prices for today
prices = [float(p["total"]) for p in mock_prices]
# Calculate expected values
expected_mean = calculate_mean(prices) # 40.0
expected_median = calculate_median(prices) # 30.0
# Verify setup
assert expected_mean == pytest.approx(40.0)
assert expected_median == pytest.approx(30.0)
def test_default_display_is_median() -> None:
"""Test that default display mode is median."""
assert DEFAULT_AVERAGE_SENSOR_DISPLAY == "median", "Default should be median for consistency"
def test_rolling_hour_average_respects_display_setting() -> None:
"""Test that rolling hour average sensors respect display configuration."""
# Create two config entries with different settings
config_mean = Mock()
config_mean.options = {CONF_AVERAGE_SENSOR_DISPLAY: "mean"}
config_median = Mock()
config_median.options = {CONF_AVERAGE_SENSOR_DISPLAY: "median"}
# Test that the setting is read correctly
assert config_mean.options.get(CONF_AVERAGE_SENSOR_DISPLAY) == "mean"
assert config_median.options.get(CONF_AVERAGE_SENSOR_DISPLAY) == "median"
def test_calculations_always_use_mean_internally() -> None:
"""
Test that internal calculations (like volatility) always use mean, not median.
This verifies that CONF_AVERAGE_SENSOR_DISPLAY only affects STATE DISPLAY,
not internal calculations that depend on averages.
For example:
- Volatility calculation uses mean (standard deviation / mean)
- Price differences use mean
- Trend detection uses mean
The display setting should NOT affect these calculations.
"""
# Sample data with different mean and median
prices = [10.0, 20.0, 30.0, 40.0, 100.0]
# Calculate mean
mean = calculate_mean(prices) # 40.0
# Volatility calculation uses mean (coefficient of variation = std_dev / mean)
# This should ALWAYS use mean, regardless of display setting
assert mean == pytest.approx(40.0)
# For volatility: std_dev / mean * 100
# The mean here should be 40.0, not the median (30.0)
std_dev = statistics.stdev(prices)
coefficient_of_variation = (std_dev / mean) * 100
# Verify calculation uses mean (40.0), not median (30.0)
expected_cv_with_mean = (std_dev / 40.0) * 100
expected_cv_with_median = (std_dev / 30.0) * 100
assert coefficient_of_variation == pytest.approx(expected_cv_with_mean)
assert coefficient_of_variation != pytest.approx(expected_cv_with_median), (
"Volatility calculation should use mean, not median"
)
def test_trend_calculation_uses_mean() -> None:
"""
Test that trend calculations use mean for forward-looking averages.
Trend detection compares:
- Later half mean (next 2h, 3h, or 6h)
- First half mean
These should ALWAYS use arithmetic mean for accurate trend detection,
regardless of display preference.
"""
# Two sets of prices with different distributions
first_half = [10.0, 20.0, 30.0] # mean=20.0, median=20.0
later_half = [40.0, 50.0, 100.0] # mean=63.33, median=50.0
# Calculate means (used in trend detection)
first_mean = calculate_mean(first_half)
later_mean = calculate_mean(later_half)
# Trend percentage should use means
trend_pct = ((later_mean - first_mean) / first_mean) * 100
# Verify it uses mean (not median)
assert first_mean == pytest.approx(20.0)
assert later_mean == pytest.approx(63.33, rel=0.01)
assert trend_pct > 200, "Trend should show >200% increase using means"
# If we incorrectly used medians:
first_median = statistics.median(first_half) # 20.0
later_median = statistics.median(later_half) # 50.0
wrong_trend_pct = ((later_median - first_median) / first_median) * 100
assert wrong_trend_pct == pytest.approx(150.0)
assert trend_pct != pytest.approx(wrong_trend_pct), "Trend calculation should use mean, not median"
def test_attribute_contains_alternate_value() -> None:
"""
Test that attributes contain BOTH average values for automation consistency.
Both price_mean and price_median should always be present in attributes,
regardless of which value is displayed in state. The value matching the state
will be excluded from recorder via dynamic _unrecorded_attributes.
"""
# Mock config entry with median display
mock_entry_median = Mock()
mock_entry_median.options = {CONF_AVERAGE_SENSOR_DISPLAY: "median"}
# Mock cached data
cached_data = {
"average_price_today_mean": 40.0,
"average_price_today_median": 30.0,
}
# Test median display → BOTH mean AND median in attributes
attributes_median_display = {}
add_alternate_average_attribute(
attributes_median_display,
cached_data,
"average_price_today",
config_entry=mock_entry_median,
)
assert "price_mean" in attributes_median_display, "Both values should be in attributes"
assert "price_median" in attributes_median_display, "Both values should be in attributes"
assert attributes_median_display["price_mean"] == 40.0
assert attributes_median_display["price_median"] == 30.0
# Mock config entry with mean display
mock_entry_mean = Mock()
mock_entry_mean.options = {CONF_AVERAGE_SENSOR_DISPLAY: "mean"}
# Test mean display → BOTH mean AND median in attributes
attributes_mean_display = {}
add_alternate_average_attribute(
attributes_mean_display,
cached_data,
"average_price_today",
config_entry=mock_entry_mean,
)
assert "price_median" in attributes_mean_display, "Both values should be in attributes"
assert "price_mean" in attributes_mean_display, "Both values should be in attributes"
assert attributes_mean_display["price_median"] == 30.0
assert attributes_mean_display["price_mean"] == 40.0
def test_next_avg_sensors_respect_display_setting() -> None:
"""Test that next_avg_Nh sensors calculation returns both mean and median."""
# Sample data with different mean and median
prices = [10.0, 20.0, 30.0, 40.0, 100.0]
# Calculate mean and median
mean = calculate_mean(prices)
median = calculate_median(prices)
# Verify both values are calculated
assert mean is not None, "Mean should be calculated"
assert median is not None, "Median should be calculated"
assert mean != median, "Test requires different mean and median"
assert mean == pytest.approx(40.0), f"Expected mean 40.0, got {mean}"
assert median == pytest.approx(30.0), f"Expected median 30.0, got {median}"
def test_24h_window_sensors_respect_display_setting() -> None:
"""Test that 24h trailing/leading average calculation returns both mean and median."""
# Sample data with different mean and median
prices = [10.0, 20.0, 30.0, 40.0, 100.0]
# Calculate both statistics
mean = calculate_mean(prices)
median = calculate_median(prices)
# Verify both are calculated
assert mean is not None
assert median is not None
assert mean != median, "Test requires different mean and median"
# The 24h window functions (calculate_trailing_24h_mean, calculate_leading_24h_mean)
# return (mean, median) tuples, allowing sensor to choose which to display