From c89248d493288a962b03b80eda62cef760b36bfd Mon Sep 17 00:00:00 2001 From: Julian Pawlowski Date: Sun, 12 Apr 2026 12:47:11 +0000 Subject: [PATCH] feat(services): add reason codes and schedule comparison details to find services Add structured reason codes to no-result responses for find_cheapest_block, find_cheapest_hours, and find_cheapest_schedule. Each handler now classifies why no result was returned: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, or insufficient_contiguous_window. Add include_comparison_details flag to find_cheapest_schedule. When enabled, each scheduled task includes a price_comparison field showing the most expensive alternative window (mean, min, max, start, end) for cost-savings context. Document stable reason code contracts in en.json service descriptions. Add corresponding field translations to all locales (de, nb, nl, sv). Impact: Automations and scripts can now react to why no window was found, and schedules can display concrete savings vs. worst-case pricing. --- custom_components/tibber_prices/services.yaml | 5 + .../services/find_cheapest_block.py | 28 +- .../services/find_cheapest_hours.py | 28 +- .../services/find_cheapest_schedule.py | 91 ++++++ .../tibber_prices/translations/de.json | 4 + .../tibber_prices/translations/en.json | 10 +- .../tibber_prices/translations/nb.json | 4 + .../tibber_prices/translations/nl.json | 4 + .../tibber_prices/translations/sv.json | 4 + tests/services/test_find_service_responses.py | 295 ++++++++++++++++++ 10 files changed, 468 insertions(+), 5 deletions(-) create mode 100644 tests/services/test_find_service_responses.py diff --git a/custom_components/tibber_prices/services.yaml b/custom_components/tibber_prices/services.yaml index 9e9e856..75b45ba 100644 --- a/custom_components/tibber_prices/services.yaml +++ b/custom_components/tibber_prices/services.yaml @@ -930,6 +930,11 @@ find_cheapest_schedule: output: collapsed: true fields: + include_comparison_details: + required: false + default: false + selector: + boolean: use_base_unit: required: false default: false diff --git a/custom_components/tibber_prices/services/find_cheapest_block.py b/custom_components/tibber_prices/services/find_cheapest_block.py index ed5e9db..9414cba 100644 --- a/custom_components/tibber_prices/services/find_cheapest_block.py +++ b/custom_components/tibber_prices/services/find_cheapest_block.py @@ -126,6 +126,23 @@ def _compute_price_comparison( return result +def _determine_no_window_reason( + price_info: list[dict], + filtered_price_info: list[dict], + duration_intervals: int, + *, + level_filter_active: bool, +) -> str: + """Classify why no block window could be found.""" + if not price_info: + return "no_data_in_range" + if level_filter_active and not filtered_price_info: + return "no_intervals_matching_level_filter" + if len(filtered_price_info) < duration_intervals: + return "insufficient_intervals_after_filter" + return "insufficient_contiguous_window" + + async def _handle_find_block( # noqa: PLR0915 call: ServiceCall, *, @@ -146,6 +163,7 @@ async def _handle_find_block( # noqa: PLR0915 min_price_level: str | None = call.data.get("min_price_level") include_comparison_details: bool = call.data.get("include_comparison_details", False) power_profile: list[int] | None = call.data.get("power_profile") + level_filter_active = min_price_level is not None or max_price_level is not None duration_minutes_requested = int(duration_td.total_seconds() / 60) # Round up to nearest quarter-hour interval @@ -217,9 +235,16 @@ async def _handle_find_block( # noqa: PLR0915 result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse) if result is None: + reason = _determine_no_window_reason( + price_info, + filtered_price_info, + duration_intervals, + level_filter_active=level_filter_active, + ) _LOGGER.info( - "%s: no window found (need %d intervals, have %d after level filter)", + "%s: no window found (reason=%s, need %d intervals, have %d after level filter)", service_label, + reason, duration_intervals, len(filtered_price_info), ) @@ -232,6 +257,7 @@ async def _handle_find_block( # noqa: PLR0915 "currency": currency, "price_unit": price_unit, "window_found": False, + "reason": reason, "window": None, } diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index d3676ae..94c3a37 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -84,6 +84,23 @@ _COMMON_HOURS_SCHEMA = { FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA) +def _determine_no_intervals_reason( + price_info: list[dict], + filtered_price_info: list[dict], + total_intervals: int, + *, + level_filter_active: bool, +) -> str: + """Classify why no interval selection could be found.""" + if not price_info: + return "no_data_in_range" + if level_filter_active and not filtered_price_info: + return "no_intervals_matching_level_filter" + if len(filtered_price_info) < total_intervals: + return "insufficient_intervals_after_filter" + return "insufficient_intervals_for_constraints" + + def _build_found_response( # noqa: PLR0913 *, result: dict, @@ -207,6 +224,7 @@ async def _handle_find_hours( min_price_level: str | None = call.data.get("min_price_level") include_comparison_details: bool = call.data.get("include_comparison_details", False) power_profile: list[int] | None = call.data.get("power_profile") + level_filter_active = min_price_level is not None or max_price_level is not None total_minutes_requested = int(duration_td.total_seconds() / 60) min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES @@ -283,9 +301,16 @@ async def _handle_find_hours( result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse) if result is None: + reason = _determine_no_intervals_reason( + price_info, + filtered_price_info, + total_intervals, + level_filter_active=level_filter_active, + ) _LOGGER.info( - "%s: not enough intervals (need %d, have %d after level filter)", + "%s: no interval selection found (reason=%s, need %d, have %d after level filter)", service_label, + reason, total_intervals, len(filtered_price_info), ) @@ -300,6 +325,7 @@ async def _handle_find_hours( "currency": currency, "price_unit": price_unit, "intervals_found": False, + "reason": reason, "schedule": None, } diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index f67c193..a5e7d03 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -22,6 +22,7 @@ from custom_components.tibber_prices.const import ( ) from custom_components.tibber_prices.utils.price_window import ( calculate_window_statistics, + find_cheapest_contiguous_window, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -83,11 +84,77 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema( vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]), + vol.Optional("include_comparison_details", default=False): cv.boolean, vol.Optional("use_base_unit", default=False): cv.boolean, } ) +def _compute_task_price_comparison( + task_intervals: list[dict[str, Any]], + full_price_info: list[dict[str, Any]], + unit_factor: int, + *, + include_details: bool, +) -> dict[str, float | str | None] | None: + """Compute per-task comparison against most expensive window of same duration.""" + duration_intervals = len(task_intervals) + comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True) + if comparison_result is None: + return None + + task_stats = calculate_window_statistics(task_intervals, unit_factor=unit_factor, round_decimals=4) + comparison_stats = calculate_window_statistics( + comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4 + ) + task_mean = task_stats.get("price_mean") + comparison_mean = comparison_stats.get("price_mean") + if task_mean is None or comparison_mean is None: + return None + + comparison_window_start = comparison_result["intervals"][0]["startsAt"] + if not isinstance(comparison_window_start, str): + comparison_window_start = comparison_window_start.isoformat() + + result: dict[str, float | str | None] = { + "comparison_price_mean": comparison_mean, + "price_difference": abs(round(float(comparison_mean) - float(task_mean), 4)), + "comparison_window_start": comparison_window_start, + } + + if include_details: + result["comparison_price_min"] = comparison_stats.get("price_min") + result["comparison_price_max"] = comparison_stats.get("price_max") + last_start = comparison_result["intervals"][-1]["startsAt"] + if not isinstance(last_start, str): + last_start = last_start.isoformat() + result["comparison_window_end"] = ( + datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES) + ).isoformat() + + return result + + +def _determine_schedule_reason( + *, + all_tasks_scheduled: bool, + assignments_count: int, + price_info: list[dict[str, Any]], + filtered_price_info: list[dict[str, Any]], + level_filter_active: bool, +) -> str | None: + """Classify schedule outcome reason for automation-friendly no-result handling.""" + if all_tasks_scheduled: + return None + if not price_info: + return "no_data_in_range" + if level_filter_active and not filtered_price_info: + return "no_intervals_matching_level_filter" + if assignments_count == 0: + return "insufficient_contiguous_window" + return "insufficient_contiguous_window_for_some_tasks" + + def _find_cheapest_window_in_pool( pool: list[dict[str, Any]], duration_intervals: int, @@ -156,6 +223,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: use_base_unit: bool = call.data.get("use_base_unit", False) max_price_level: str | None = call.data.get("max_price_level") min_price_level: str | None = call.data.get("min_price_level") + include_comparison_details: bool = call.data.get("include_comparison_details", False) + level_filter_active = min_price_level is not None or max_price_level is not None # Round gap up to nearest quarter interval gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0 @@ -236,6 +305,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) if not filtered_price_info: + reason = _determine_schedule_reason( + all_tasks_scheduled=False, + assignments_count=0, + price_info=price_info, + filtered_price_info=filtered_price_info, + level_filter_active=level_filter_active, + ) return { "home_id": home_id, "search_start": search_start.isoformat(), @@ -243,6 +319,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: "currency": currency, "price_unit": price_unit, "all_tasks_scheduled": False, + "reason": reason, "tasks": [], "total_estimated_cost": None, } @@ -295,6 +372,12 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: "duration_minutes": task["duration_minutes"], **stats, "intervals": task_response_intervals, + "price_comparison": _compute_task_price_comparison( + task_intervals, + price_info, + unit_factor, + include_details=include_comparison_details, + ), } ) @@ -308,6 +391,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None all_scheduled = len(unscheduled) == 0 + reason = _determine_schedule_reason( + all_tasks_scheduled=all_scheduled, + assignments_count=len(assignments), + price_info=price_info, + filtered_price_info=filtered_price_info, + level_filter_active=level_filter_active, + ) _LOGGER.info( "%s: scheduled %d/%d tasks, total_cost=%s", @@ -324,6 +414,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: "currency": currency, "price_unit": price_unit, "all_tasks_scheduled": all_scheduled, + "reason": reason, "unscheduled_tasks": unscheduled or None, "tasks": assignments, "total_estimated_cost": total_estimated_cost, diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index 5314ba9..5dda4db 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -1847,6 +1847,10 @@ "name": "Minimale Preisstufe", "description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." }, + "include_comparison_details": { + "name": "Vergleichsdetails einbeziehen", + "description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen." + }, "use_base_unit": { "name": "Basiswährung verwenden", "description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen." diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index 70e4ec8..d5462f7 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -1387,7 +1387,7 @@ }, "find_cheapest_block": { "name": "Find Cheapest Block", - "description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.", + "description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).", "sections": { "search_range": { "name": "Search Range", @@ -1571,7 +1571,7 @@ }, "find_cheapest_hours": { "name": "Find Cheapest Hours", - "description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.", + "description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).", "sections": { "search_range": { "name": "Search Range", @@ -1763,7 +1763,7 @@ }, "find_cheapest_schedule": { "name": "Find Cheapest Schedule", - "description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.", + "description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).", "sections": { "scheduling_options": { "name": "Scheduling Options", @@ -1847,6 +1847,10 @@ "name": "Minimum Price Level", "description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals." }, + "include_comparison_details": { + "name": "Include Comparison Details", + "description": "Add per-task price_comparison details (comparison_price_min, comparison_price_max, comparison_window_end) to compare each selected task window against the opposite extreme window of the same duration." + }, "use_base_unit": { "name": "Use Base Currency Unit", "description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations." diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index ac4f91f..b199bc4 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -1847,6 +1847,10 @@ "name": "Minimalt prisnivaae", "description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller." }, + "include_comparison_details": { + "name": "Inkluder sammenligningsdetaljer", + "description": "Legger til ekstra price_comparison-detaljer per oppgave (comparison_price_min, comparison_price_max, comparison_window_end) for aa sammenligne valgt vindu med motsatt ekstremvindu med samme varighet." + }, "use_base_unit": { "name": "Bruk basisvaluta", "description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger." diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 9f34254..77dcfb2 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -1847,6 +1847,10 @@ "name": "Minimaal prijsniveau", "description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen." }, + "include_comparison_details": { + "name": "Vergelijkingsdetails opnemen", + "description": "Voegt per taak extra price_comparison-details toe (comparison_price_min, comparison_price_max, comparison_window_end) om het gekozen venster te vergelijken met het tegenovergestelde extreme venster met dezelfde duur." + }, "use_base_unit": { "name": "Basisvaluta gebruiken", "description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen." diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index f6eb1b9..34c5f36 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -1847,6 +1847,10 @@ "name": "Minimal prisnivaae", "description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall." }, + "include_comparison_details": { + "name": "Inkludera jaemfoerelsedetaljer", + "description": "Laegger till extra price_comparison-detaljer per uppgift (comparison_price_min, comparison_price_max, comparison_window_end) foer att jaemfoera valt foenster med motsatt extremfoenster med samma laengd." + }, "use_base_unit": { "name": "Använd basvaluta", "description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar." diff --git a/tests/services/test_find_service_responses.py b/tests/services/test_find_service_responses.py new file mode 100644 index 0000000..9c4f143 --- /dev/null +++ b/tests/services/test_find_service_responses.py @@ -0,0 +1,295 @@ +"""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 +from custom_components.tibber_prices.services import find_cheapest_hours as hours_module +from custom_components.tibber_prices.services import 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, + }, + ) + 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, + }, + ) + 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