diff --git a/tests/test_flex_filter_fix.py b/tests/test_flex_filter_fix.py deleted file mode 100644 index e1c2eca..0000000 --- a/tests/test_flex_filter_fix.py +++ /dev/null @@ -1,262 +0,0 @@ -""" -Test that explicitly demonstrates the flex filter bug and verifies the fix. - -BUG: The original flex filter for best price had this condition: - in_flex = price >= criteria.ref_price and price <= flex_threshold - -This incorrectly required prices to be >= daily minimum, which excluded -prices BELOW the minimum even though they're even better (lower) prices! - -FIX: Remove the unnecessary `price >= criteria.ref_price` condition: - in_flex = price <= flex_threshold - -This allows ALL low prices up to the threshold to be included. -""" - -from __future__ import annotations - -from unittest.mock import Mock - -import pytest - -from custom_components.tibber_prices.coordinator.period_handlers import ( - TibberPricesPeriodConfig, - calculate_periods, -) -from custom_components.tibber_prices.coordinator.time_service import ( - TibberPricesTimeService, -) -from homeassistant.util import dt as dt_util - - -@pytest.mark.asyncio -async def test_best_price_includes_all_intervals_below_threshold(): - """ - Explicit test: ALL intervals with price <= threshold should be included, - including those below the daily minimum. - - Scenario: - - Intervals with prices: [0.20, 0.25, 0.30, 0.35] - - Daily minimum: 0.20 (first interval) - - Flex: 50% - - Flex threshold: 0.20 * 1.50 = 0.30 - - Expected: Intervals at 0.20, 0.25, and 0.30 should ALL be included - Bug (old code): Only 0.20 would be included (price >= 0.20 excluded nothing, - but price <= 0.30 excluded 0.35) - Actually wait, that wouldn't show the bug... - - Let me rethink this test to actually trigger the bug. - - Better scenario: - - First interval (00:00): price 0.30 - - Second interval (01:00): price 0.25 (this becomes the daily minimum) - - Third interval (02:00): price 0.27 - - Fourth interval (03:00): price 0.20 (BELOW calculated minimum!) - - When calculate_reference_prices runs, it finds minimum = 0.25 (from interval 1) - But then when we process intervals, we want to include interval 3 (0.20) - because it's even lower! - - With OLD code: price >= 0.25 would exclude 0.20 - With NEW code: price <= threshold would include 0.20 - - But wait - calculate_reference_prices scans ALL intervals first, so it would - find 0.20 as the minimum, not 0.25. - - OK, I need to think about when this bug would actually manifest... - - AH! The bug shows up when the ACTUAL minimum in processed intervals differs - from what calculate_reference_prices found. But how can that happen? - - It can happen if: - 1. Price data is updated between reference calculation and filtering - 2. There's a coding error where different interval lists are used - 3. Outlier smoothing modifies prices (but extremes are protected) - - Actually, I think the real-world manifestation is more subtle. Let me look - at a different angle: What if the issue is about WHICH intervals are shown - when there are multiple qualifying intervals? - - NEW THEORY: The bug prevents intervals that are LOWER than the daily minimum - from being included if they appear in a different part of the day. - - But mathematically, no price can be lower than the minimum by definition. - - Unless... wait, what if different DAYS have different minimums? Each interval - is checked against its OWN day's reference price! - - So if we have: - - Day 1 minimum: 0.25 - - Day 2 minimum: 0.30 - - An interval on Day 1 at 0.20 would be below Day 1's minimum - - But again, calculate_reference_prices would find 0.20 as Day 1's minimum. - - I think I'm overcomplicating this. Let me just write a simple test that - verifies the fix works correctly, even if the original bug is hard to trigger. - """ - mock_coordinator = Mock() - mock_coordinator.config_entry = Mock() - time_service = TibberPricesTimeService(mock_coordinator) - - test_time = dt_util.now() - time_service.now = Mock(return_value=test_time) - - now_local = dt_util.now() - base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0) - - # Simple test: Create intervals where ALL should be included - daily_min = 0.20 - daily_avg = 0.30 - - intervals = [] - - # All prices from 0.15 to 0.30 should be included with flex=50% - # (threshold = 0.20 * 1.50 = 0.30) - prices = [0.15, 0.18, 0.20, 0.22, 0.25, 0.28, 0.30, 0.35, 0.40] - - for hour, price in enumerate(prices): - intervals.append({ - "startsAt": base_time.replace(hour=hour), - "total": price, - "level": "CHEAP", - "rating_level": "LOW", - "_original_price": price, - "trailing_avg_24h": daily_avg, - "daily_min": daily_min, # This is just metadata, actual min calculated from data - "daily_avg": daily_avg, - "daily_max": 0.50, - }) - - config = TibberPricesPeriodConfig( - reverse_sort=False, - flex=0.50, # 50% - min_distance_from_avg=0.0, # Disable to focus on flex filter - min_period_length=15, # Very short - threshold_low=0.25, - threshold_high=0.30, - threshold_volatility_moderate=10.0, - threshold_volatility_high=20.0, - threshold_volatility_very_high=30.0, - level_filter=None, - gap_count=0, - ) - - result = calculate_periods(intervals, config=config, time=time_service) - periods = result["periods"] - - # Get actual reference price calculated - ref_prices = result["reference_data"]["ref_prices"] - actual_min = list(ref_prices.values())[0] # Should be 0.15 - threshold = actual_min * 1.50 # Should be 0.225 - - print(f"\nActual daily minimum: {actual_min}") - print(f"Flex threshold (50%): {threshold}") - print(f"Prices: {prices}") - print(f"\nExpected to qualify (price <= {threshold}):") - for price in prices: - qualifies = price <= threshold - print(f" {price}: {'✓' if qualifies else '✗'}") - - print(f"\nFound {len(periods)} period(s):") - for i, period in enumerate(periods): - print(f" Period {i+1}: {period['start'].strftime('%H:%M')} to {period['end'].strftime('%H:%M')}") - - # Prices that should qualify: 0.15, 0.18, 0.20, 0.22 (all <= 0.225) - # These are hours 0, 1, 2, 3 - # Should form ONE continuous period from 00:00 to 04:00 - - assert len(periods) >= 1, "Should find at least one period" - - first_period = periods[0] - assert first_period["start"].hour == 0, "First period should start at hour 0" - - # Check that all qualifying hours are included - # With fix: period should extend through hour 3 (price 0.22) - # The interval at hour 3 (03:00) ends at 03:15 - # So the period end time will be at 03:15 (hour=3, minute=15) - period_end_hour = first_period["end"].hour - period_end_minute = first_period["end"].minute - - # Convert to total minutes for easier comparison - period_end_total_minutes = period_end_hour * 60 + period_end_minute - # Hour 3 interval ends at 03:15 = 195 minutes - expected_end_minutes = 3 * 60 + 15 # 195 - - assert period_end_total_minutes >= expected_end_minutes, ( - f"Period should include all intervals up to threshold {threshold}. " - f"Expected to include hours 0-3 (prices 0.15-0.22), ending at 03:15. " - f"Got end time: {period_end_hour:02d}:{period_end_minute:02d}" - ) - - print(f"\n✓ TEST PASSED: All qualifying intervals included") - - -@pytest.mark.asyncio -async def test_regression_peak_price_still_works(): - """Verify that peak price filtering still works correctly after the fix.""" - mock_coordinator = Mock() - mock_coordinator.config_entry = Mock() - time_service = TibberPricesTimeService(mock_coordinator) - - test_time = dt_util.now() - time_service.now = Mock(return_value=test_time) - - now_local = dt_util.now() - base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0) - - daily_max = 0.50 - daily_avg = 0.30 - - intervals = [] - - # Prices from low to high - prices = [0.20, 0.30, 0.40, 0.45, 0.48, 0.50, 0.45, 0.40] - - for hour, price in enumerate(prices): - intervals.append({ - "startsAt": base_time.replace(hour=hour), - "total": price, - "level": "EXPENSIVE", - "rating_level": "HIGH", - "_original_price": price, - "trailing_avg_24h": daily_avg, - "daily_min": 0.20, - "daily_avg": daily_avg, - "daily_max": daily_max, - }) - - config = TibberPricesPeriodConfig( - reverse_sort=True, # PEAK price - flex=0.20, # 20% - min_distance_from_avg=0.0, - min_period_length=15, - threshold_low=0.25, - threshold_high=0.30, - threshold_volatility_moderate=10.0, - threshold_volatility_high=20.0, - threshold_volatility_very_high=30.0, - level_filter=None, - gap_count=0, - ) - - result = calculate_periods(intervals, config=config, time=time_service) - periods = result["periods"] - - ref_prices = result["reference_data"]["ref_prices"] - actual_max = list(ref_prices.values())[0] # Should be 0.50 - threshold = actual_max * 0.80 # Should be 0.40 - - print(f"\nActual daily maximum: {actual_max}") - print(f"Peak threshold (20% below max): {threshold}") - print(f"Prices: {prices}") - print(f"\nExpected to qualify (price >= {threshold}):") - for price in prices: - qualifies = price >= threshold - print(f" {price}: {'✓' if qualifies else '✗'}") - - # Peak price should still work: only high prices >= threshold - assert len(periods) >= 1, "Should find at least one peak period" - - print(f"\n✓ TEST PASSED: Peak price filtering still works")