diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd26e8..5db939d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- Internal: typed runtime data via `PriceHawkData` dataclass + `PriceHawkConfigEntry` alias; `entry.runtime_data` replaces `hass.data[DOMAIN][entry_id]` for coordinator storage. Service handlers (`analyze_csv`, `backfill_history`, `rank_alternatives`) re-resolve the coordinator on every invocation, eliminating a stale-closure bug latent across `OptionsFlowWithReload` cycles. Unload re-ordered: `async_unload_platforms` runs first, coordinator teardown only on success. Multi-entry service deregistration now sourced from `hass.config_entries.async_entries(DOMAIN)`. No user-facing change. (Phase 7 / PR-1) + ### Documentation - Refreshed `README.md` for Phase 3: per-window dashboard tabs, ranked-alternatives table, CDR `Other (no API)` retailer path, partial-window mode, services FAQ. diff --git a/DECISIONS.md b/DECISIONS.md index d5e641d..d91aaa7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,7 +5,33 @@ -## 2026-05-14 — Phase 1 entry corrections +## 2026-05-20 — Phase 7 Plan 01 (typed runtime data) + +### D-P7-1 — Adopt `PriceHawkConfigEntry = ConfigEntry[PriceHawkData]` typed-entry alias +**Decision:** Introduce `custom_components/pricehawk/data.py` exporting `PriceHawkData` (`@dataclass(slots=True)`) and `PriceHawkConfigEntry: TypeAlias = ConfigEntry[PriceHawkData]`. All future `entry: PriceHawkConfigEntry` annotations use this alias. Coordinator storage moves from `hass.data[DOMAIN][entry_id]` to `entry.runtime_data`. +**Rationale:** PR-1 from `PriceHawk v2 — Deep Research Round 2 (Scope-Corrected).md`, Wave 1 Foundation. Required prerequisite for Phase 8 Silver-compliance handlers (reauth, reconfigure, diagnostics) which all need a single typed object to reach into. HA core convention since 2024. +**Alternatives:** Continue using `hass.data[DOMAIN]` — rejected as it blocks Silver compliance, leaks the multi-entry sentinel responsibility, and forces every consumer to know the entry-id-keyed indirection. +**Consequences:** Every Phase 8 PR consumes this alias. Dataclass kept mutable (`slots=True`, NOT `frozen`) so additive fields can land in later PRs without re-creating the object. + +### D-P7-2 — Service handlers must re-resolve coordinator on every invocation +**Decision:** The three registered service handlers (`analyze_csv`, `backfill_history`, `rank_alternatives`) read the coordinator via a `_resolve_coordinator()` helper that reads `entry.runtime_data` on every call. No closure capture of `coordinator` in setup scope. +**Rationale:** Latent pre-existing bug surfaced (not introduced) by this PR: `OptionsFlowWithReload` (HA 2026.3+) triggers a setup→unload→setup cycle on options save. The entry object survives (same identity) but `entry.runtime_data.coordinator` is replaced with a fresh `PriceHawkCoordinator`. A handler that closed over `coordinator` from the original `async_setup_entry` scope would silently keep firing methods on the dead coordinator forever. The typed-runtime-data migration makes the failure mode more visible, so fixed in the same PR. +**Alternatives:** Re-register the services on every `async_setup_entry` — rejected because the multi-entry sentinel only deregisters when the LAST entry unloads. Multiple registrations of the same service name in HA throw. +**Consequences:** Sets the pattern for all future service handlers in this integration. `test_service_handlers_resolve_fresh_coordinator` enforces it. + +### D-P7-3 — `async_unload_entry` reordered: platform-unload first, coordinator teardown only on success +**Decision:** `async_unload_platforms` runs FIRST in `async_unload_entry`. If it returns False, return False immediately with `entry.runtime_data` left intact so HA can retry the unload. Coordinator timer cancellation + state persistence happen ONLY after a successful platform unload. +**Rationale:** The previous order (cancel timers → persist state → unload platforms) left the entry in a half-unloaded state on platform-unload failure — coordinator was already torn down with no recovery path. Audit Gap #4. +**Alternatives:** Try/finally pattern with restore-on-failure — rejected as the simpler reorder produces equivalent correctness without restore complexity. +**Consequences:** HA can safely retry `async_unload_entry` after a failure. Documented in `` MANUAL SMOKE step (multi-entry add/remove cycle). + +### D-P7-4 — Multi-entry singleton-service sentinel via `hass.config_entries.async_entries(DOMAIN)` +**Decision:** Singleton-service deregistration (the three services unregistered when the last PriceHawk entry leaves) now reads the config-entries registry, not `hass.data`. Filters out the entry being unloaded explicitly via `entry_id` comparison (whether HA includes or excludes it from `async_entries(DOMAIN)` at unload time varies by HA version — explicit filter is version-safe). +**Rationale:** PR removed `hass.data[DOMAIN]` entirely. Audit Gap #1: previous sentinel (`if not hass.data.get(DOMAIN)`) became unreachable garbage after the removal. Production-breaking for any HACS user with two PriceHawk entries (one per house) — either premature deregistration (services break) or services never unregistered (leak across HA restarts). +**Alternatives:** Module-level counter — rejected because it diverges from HA's authoritative source of truth (config-entries registry). +**Consequences:** `test_multi_entry_service_lifecycle` enforces the contract. Future entries (e.g. multi-NMI households) work correctly. + + ### D-P0-7 — Evaluator bug fixes (post-gate, during Phase 1 parity work) **Decision:** Two bugs corrected in `scripts/cdr_evaluator_proto.py`. Phase 0 gate result stands — bugs were masked by Plan C2's specifics + your hand-calc presumably caught the right semantics. Re-verify with `phase_0_verify.py --markdown`. diff --git a/custom_components/pricehawk/__init__.py b/custom_components/pricehawk/__init__.py index 78d3087..1c46b8e 100644 --- a/custom_components/pricehawk/__init__.py +++ b/custom_components/pricehawk/__init__.py @@ -2,7 +2,6 @@ import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( @@ -16,22 +15,22 @@ remove_panel, setup_panel_iframe, ) +from .data import PriceHawkConfigEntry, PriceHawkData _LOGGER = logging.getLogger(__name__) PLATFORMS = ["sensor"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PriceHawkConfigEntry) -> bool: """Set up PriceHawk from a config entry.""" _LOGGER.info("Setting up PriceHawk integration (entry=%s)", entry.entry_id) - hass.data.setdefault(DOMAIN, {}) coordinator = PriceHawkCoordinator(hass, entry) await coordinator.async_restore_state() await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = PriceHawkData(coordinator=coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -67,6 +66,14 @@ async def _backfill_after_ranking() -> None: # OptionsFlowWithReload handles reloading automatically — # do NOT add an update_listener here (HA 2026.3+ forbids combining them). + # Service handlers re-resolve the coordinator from entry.runtime_data on + # every invocation. The entry object survives OptionsFlowWithReload, but + # the coordinator inside runtime_data is replaced — a captured closure + # reference would silently point at the dead instance after reload. + def _resolve_coordinator() -> PriceHawkCoordinator | None: + data: PriceHawkData | None = getattr(entry, "runtime_data", None) + return data.coordinator if data is not None else None + # Register CSV analysis service async def handle_analyze_csv(call: object) -> None: """Handle the analyze_csv service call from dashboard. @@ -74,6 +81,10 @@ async def handle_analyze_csv(call: object) -> None: Accepts pre-parsed CSV rows from the dashboard JavaScript and runs them through the user's CONFIGURED tariff rates (not plan defaults). """ + coord = _resolve_coordinator() + if coord is None: + _LOGGER.warning("analyze_csv: coordinator not available; entry unloaded?") + return rows = call.data.get("rows", []) # type: ignore[attr-defined] if not rows: _LOGGER.error("No CSV rows provided to analyze_csv service") @@ -90,8 +101,8 @@ async def handle_analyze_csv(call: object) -> None: ) # Store in coordinator for dashboard access via entity attributes - coordinator.data["csv_comparison"] = result - coordinator.async_set_updated_data(coordinator.data) + coord.data["csv_comparison"] = result + coord.async_set_updated_data(coord.data) _LOGGER.info( "CSV analysis complete: %s saves $%.2f", @@ -107,6 +118,10 @@ async def handle_analyze_csv(call: object) -> None: # coordinator method; status surfaces via # ``sensor.pricehawk_backfill_status``. async def handle_backfill(call: object) -> None: + coord = _resolve_coordinator() + if coord is None: + _LOGGER.warning("backfill_history: coordinator not available; entry unloaded?") + return raw_days = call.data.get("days", 30) # type: ignore[attr-defined] try: days_back = max(1, min(int(raw_days), 90)) @@ -115,7 +130,7 @@ async def handle_backfill(call: object) -> None: "backfill_history: invalid days=%r, using default 30", raw_days, ) days_back = 30 - await coordinator.async_run_backfill(days_back=days_back) + await coord.async_run_backfill(days_back=days_back) hass.services.async_register(DOMAIN, "backfill_history", handle_backfill) @@ -125,6 +140,10 @@ async def handle_backfill(call: object) -> None: # switching plans (so the alternatives ranking reflects the new # distributor / postcode immediately). async def handle_rank_alternatives(call: object) -> None: + coord = _resolve_coordinator() + if coord is None: + _LOGGER.warning("rank_alternatives: coordinator not available; entry unloaded?") + return # CR-fix: malformed service payload (e.g. ``top_k: "abc"`` from # a typo in a YAML automation) would raise ValueError/TypeError # and fail the call. Coerce defensively + fall back to default. @@ -137,7 +156,7 @@ async def handle_rank_alternatives(call: object) -> None: ) top_k = 20 top_k = max(1, min(top_k, 100)) - result = await coordinator.async_run_ranking_job(top_k=top_k) + result = await coord.async_run_ranking_job(top_k=top_k) _LOGGER.info( "rank_alternatives service: ran successfully, %d result(s)", len(result), @@ -151,23 +170,40 @@ async def handle_rank_alternatives(call: object) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" +async def async_unload_entry(hass: HomeAssistant, entry: PriceHawkConfigEntry) -> bool: + """Unload a config entry. + + Order matters: platform-unload runs FIRST. If it fails, the coordinator + and runtime_data are left intact so HA can retry. Only on success do we + cancel timers, persist state, and (if this was the last entry) tear down + the singleton services. + """ _LOGGER.info("Unloading PriceHawk integration (entry=%s)", entry.entry_id) - coordinator: PriceHawkCoordinator | None = hass.data[DOMAIN].pop( - entry.entry_id, None - ) - if coordinator: - coordinator.cancel_persist() - coordinator.cancel_ranking() - await coordinator.async_persist_state() + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if not unload_ok: + return False + + data: PriceHawkData | None = getattr(entry, "runtime_data", None) + if data is not None: + data.coordinator.cancel_persist() + data.coordinator.cancel_ranking() + await data.coordinator.async_persist_state() await remove_panel(hass) - # Unregister services if no more entries remain - if not hass.data.get(DOMAIN): + # Multi-entry sentinel: only unregister the singleton services when THIS + # is the last remaining entry. Uses the config-entries registry — NOT + # hass.data, which is no longer maintained. The entry being unloaded may + # or may not still appear in async_entries(DOMAIN) depending on HA + # version, so filter it out explicitly. + remaining = [ + e for e in hass.config_entries.async_entries(DOMAIN) + if e.entry_id != entry.entry_id + ] + if not remaining: hass.services.async_remove(DOMAIN, "analyze_csv") hass.services.async_remove(DOMAIN, "backfill_history") hass.services.async_remove(DOMAIN, "rank_alternatives") - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True diff --git a/custom_components/pricehawk/data.py b/custom_components/pricehawk/data.py new file mode 100644 index 0000000..a0b60de --- /dev/null +++ b/custom_components/pricehawk/data.py @@ -0,0 +1,21 @@ +"""Typed runtime data for the PriceHawk integration (Phase 7 / PR-1).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeAlias + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from .coordinator import PriceHawkCoordinator + + +@dataclass(slots=True) +class PriceHawkData: + """Runtime data attached to a PriceHawk ConfigEntry via entry.runtime_data.""" + + coordinator: "PriceHawkCoordinator" + + +PriceHawkConfigEntry: TypeAlias = ConfigEntry[PriceHawkData] diff --git a/custom_components/pricehawk/sensor.py b/custom_components/pricehawk/sensor.py index dcaa796..83c5aea 100644 --- a/custom_components/pricehawk/sensor.py +++ b/custom_components/pricehawk/sensor.py @@ -20,6 +20,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN +from .data import PriceHawkConfigEntry _LOGGER = logging.getLogger(__name__) @@ -804,11 +805,20 @@ def extra_state_attributes(self) -> dict[str, Any]: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PriceHawkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up PriceHawk sensors from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + # HA's platform-setup lifecycle guarantees this runs after async_setup_entry + # in __init__.py has populated entry.runtime_data. The assert narrows the + # Optional[PriceHawkData] for mypy and loud-fails any test fixture that + # violates the lifecycle, instead of producing an AttributeError on a + # downstream .coordinator access. + data = entry.runtime_data + assert data is not None, ( + "entry.runtime_data missing — async_setup_entry in __init__.py must run first" + ) + coordinator = data.coordinator entities: list[SensorEntity] = [] diff --git a/tests/test_runtime_data.py b/tests/test_runtime_data.py new file mode 100644 index 0000000..6367e20 --- /dev/null +++ b/tests/test_runtime_data.py @@ -0,0 +1,330 @@ +"""Regression tests for typed runtime data (Phase 7 / PR-1). + +Covers the contract surface introduced by ``custom_components.pricehawk.data``: +single-entry setup/unload, multi-entry service lifecycle, OptionsFlowWithReload +round-trip, service-handler closure freshness, and a static grep belt-and-braces +guard against the legacy ``hass.data[DOMAIN]`` pattern. + +Async pattern matches the rest of the suite: ``asyncio.run(...)`` inside sync +tests, not ``pytest-asyncio``. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_coordinator() -> MagicMock: + """Build a stub PriceHawkCoordinator with the methods __init__.py touches.""" + coord = MagicMock() + coord.async_restore_state = AsyncMock() + coord.async_config_entry_first_refresh = AsyncMock() + coord.async_run_ranking_job = AsyncMock(return_value=[]) + coord.async_run_backfill = AsyncMock() + coord.async_persist_state = AsyncMock() + coord.async_set_updated_data = MagicMock() + coord.schedule_persist = MagicMock() + coord.schedule_daily_ranking = MagicMock() + coord.cancel_persist = MagicMock() + coord.cancel_ranking = MagicMock() + coord._ranking_lock = asyncio.Lock() + coord.data = {} + return coord + + +def _make_hass( + registered_entries: list[Any] | None = None, + unload_platforms_result: bool = True, +) -> MagicMock: + """Build a stub HomeAssistant with the surfaces __init__.py reaches into.""" + hass = MagicMock() + hass.data = {} + hass.config_entries = MagicMock() + hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True) + hass.config_entries.async_unload_platforms = AsyncMock( + return_value=unload_platforms_result + ) + hass.config_entries.async_entries = MagicMock( + return_value=list(registered_entries or []) + ) + hass.services = MagicMock() + hass.services.async_register = MagicMock() + hass.services.async_remove = MagicMock() + + # async_create_task: swallow the coroutine cleanly so we don't get + # "coroutine was never awaited" warnings during teardown. + def _swallow(coro: Any) -> MagicMock: + if hasattr(coro, "close"): + coro.close() + return MagicMock() + + hass.async_create_task = MagicMock(side_effect=_swallow) + hass.async_add_executor_job = AsyncMock() + return hass + + +def _make_entry(entry_id: str = "entry-A") -> MagicMock: + """Build a stub ConfigEntry — runtime_data starts None, set by setup.""" + entry = MagicMock() + entry.entry_id = entry_id + entry.options = {} + entry.runtime_data = None + return entry + + +def _patch_deps(coord: MagicMock): + """Context-manager bundle for the four collaborators we always patch.""" + return ( + patch( + "custom_components.pricehawk.PriceHawkCoordinator", + return_value=coord, + ), + patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), + patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), + ) + + +def _patch_deps_iter(coords: list[MagicMock]): + """Same as _patch_deps but each setup pulls the next coordinator from the list.""" + coords_iter = iter(coords) + + def _next_coord(*_args: Any, **_kwargs: Any) -> MagicMock: + return next(coords_iter) + + return ( + patch( + "custom_components.pricehawk.PriceHawkCoordinator", + side_effect=_next_coord, + ), + patch("custom_components.pricehawk.copy_www_assets", new=AsyncMock()), + patch("custom_components.pricehawk.setup_panel_iframe", new=AsyncMock()), + patch("custom_components.pricehawk.remove_panel", new=AsyncMock()), + ) + + +# --------------------------------------------------------------------------- +# AC-1 / AC-2: setup writes typed runtime_data +# --------------------------------------------------------------------------- + + +def test_setup_writes_runtime_data(): + """After async_setup_entry, entry.runtime_data is a PriceHawkData with coordinator.""" + from custom_components.pricehawk import async_setup_entry + from custom_components.pricehawk.data import PriceHawkData + + coord = _make_coordinator() + hass = _make_hass() + entry = _make_entry() + + p1, p2, p3, p4 = _patch_deps(coord) + with p1, p2, p3, p4: + result = asyncio.run(async_setup_entry(hass, entry)) + + assert result is True + assert isinstance(entry.runtime_data, PriceHawkData) + assert entry.runtime_data.coordinator is coord + + +# --------------------------------------------------------------------------- +# AC-4: unload runs platform-unload FIRST +# --------------------------------------------------------------------------- + + +def test_unload_runs_platform_unload_first(): + """If async_unload_platforms returns False, coordinator is NOT torn down.""" + from custom_components.pricehawk import async_setup_entry, async_unload_entry + + coord = _make_coordinator() + hass = _make_hass(unload_platforms_result=False) + entry = _make_entry() + + p1, p2, p3, p4 = _patch_deps(coord) + with p1, p2, p3, p4: + asyncio.run(async_setup_entry(hass, entry)) + assert entry.runtime_data is not None + original_data = entry.runtime_data + + result = asyncio.run(async_unload_entry(hass, entry)) + + assert result is False + assert coord.cancel_persist.call_count == 0 + assert coord.cancel_ranking.call_count == 0 + assert coord.async_persist_state.call_count == 0 + assert entry.runtime_data is original_data, ( + "runtime_data must survive failed platform-unload so HA can retry" + ) + + +# --------------------------------------------------------------------------- +# AC-2: unload never touches hass.data +# --------------------------------------------------------------------------- + + +def test_unload_does_not_touch_hass_data(): + """Successful unload leaves hass.data untouched.""" + from custom_components.pricehawk import async_setup_entry, async_unload_entry + + coord = _make_coordinator() + hass = _make_hass(unload_platforms_result=True) + entry = _make_entry() + + p1, p2, p3, p4 = _patch_deps(coord) + with p1, p2, p3, p4: + asyncio.run(async_setup_entry(hass, entry)) + hass_data_snapshot = dict(hass.data) + result = asyncio.run(async_unload_entry(hass, entry)) + + assert result is True + assert hass.data == hass_data_snapshot, ( + "Unload must not mutate hass.data — runtime_data is the only storage now" + ) + coord.cancel_persist.assert_called_once() + coord.cancel_ranking.assert_called_once() + coord.async_persist_state.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# AC-2b: multi-entry service lifecycle +# --------------------------------------------------------------------------- + + +def test_multi_entry_service_lifecycle(): + """Two entries: services persist after first unload, removed after second.""" + from custom_components.pricehawk import async_setup_entry, async_unload_entry + + entry_a = _make_entry("entry-A") + entry_b = _make_entry("entry-B") + + coord_a = _make_coordinator() + coord_b = _make_coordinator() + + hass = _make_hass() + + p1, p2, p3, p4 = _patch_deps_iter([coord_a, coord_b]) + with p1, p2, p3, p4: + asyncio.run(async_setup_entry(hass, entry_a)) + asyncio.run(async_setup_entry(hass, entry_b)) + + # First unload: entry_b still registered, services must stay. + hass.config_entries.async_entries.return_value = [entry_a, entry_b] + hass.services.async_remove.reset_mock() + asyncio.run(async_unload_entry(hass, entry_a)) + assert hass.services.async_remove.call_count == 0, ( + "Services must remain registered while another entry exists" + ) + + # Second unload: last entry → services removed exactly once each. + hass.config_entries.async_entries.return_value = [entry_b] + hass.services.async_remove.reset_mock() + asyncio.run(async_unload_entry(hass, entry_b)) + removed = {call.args[1] for call in hass.services.async_remove.call_args_list} + assert removed == {"analyze_csv", "backfill_history", "rank_alternatives"} + assert hass.services.async_remove.call_count == 3 + + +# --------------------------------------------------------------------------- +# AC-4b: OptionsFlowWithReload round-trip preserves contract +# --------------------------------------------------------------------------- + + +def test_options_flow_reload_cycle(): + """unload → setup yields a NEW coordinator in runtime_data; old timers cancelled.""" + from custom_components.pricehawk import async_setup_entry, async_unload_entry + + entry = _make_entry() + hass = _make_hass(unload_platforms_result=True) + + coord_v1 = _make_coordinator() + coord_v2 = _make_coordinator() + + p1, p2, p3, p4 = _patch_deps_iter([coord_v1, coord_v2]) + with p1, p2, p3, p4: + asyncio.run(async_setup_entry(hass, entry)) + assert entry.runtime_data.coordinator is coord_v1 + + hass.config_entries.async_entries.return_value = [entry] + asyncio.run(async_unload_entry(hass, entry)) + coord_v1.cancel_persist.assert_called_once() + coord_v1.cancel_ranking.assert_called_once() + + # HA resets runtime_data between unload + re-setup on a reload cycle. + entry.runtime_data = None + + asyncio.run(async_setup_entry(hass, entry)) + assert entry.runtime_data.coordinator is coord_v2 + assert coord_v2 is not coord_v1 + + +# --------------------------------------------------------------------------- +# Closure freshness: service handlers re-resolve coordinator on each call +# --------------------------------------------------------------------------- + + +def test_service_handlers_resolve_fresh_coordinator(): + """After runtime_data is swapped, registered handlers see the NEW coordinator.""" + from custom_components.pricehawk import async_setup_entry + from custom_components.pricehawk.data import PriceHawkData + + original_coord = _make_coordinator() + hass = _make_hass() + entry = _make_entry() + + p1, p2, p3, p4 = _patch_deps(original_coord) + with p1, p2, p3, p4: + asyncio.run(async_setup_entry(hass, entry)) + + # Capture the rank_alternatives handler from the registration call. + rank_handler = None + for call in hass.services.async_register.call_args_list: + # args: (domain, name, handler) + if call.args[1] == "rank_alternatives": + rank_handler = call.args[2] + break + assert rank_handler is not None + + # Simulate OptionsFlowWithReload swap: replace runtime_data with a new + # PriceHawkData containing a fresh coordinator. Reset original's mock so + # we can assert it sees ZERO additional invocations after the swap + # (setup itself fires async_run_ranking_job once via hass.async_create_task). + original_coord.async_run_ranking_job.reset_mock() + new_coord = _make_coordinator() + entry.runtime_data = PriceHawkData(coordinator=new_coord) + + call_obj = SimpleNamespace(data={"top_k": 5}) + asyncio.run(rank_handler(call_obj)) + + new_coord.async_run_ranking_job.assert_awaited_once_with(top_k=5) + original_coord.async_run_ranking_job.assert_not_called() + + +# --------------------------------------------------------------------------- +# Static grep belt-and-braces against legacy patterns +# --------------------------------------------------------------------------- + + +def test_no_legacy_hass_data_reads(): + """No file in the integration may reference the legacy hass.data[DOMAIN] pattern.""" + pkg = Path(__file__).resolve().parents[1] / "custom_components" / "pricehawk" + forbidden = ( + "hass.data[DOMAIN]", + "hass.data.get(DOMAIN)", + "hass.data.setdefault(DOMAIN", + 'hasattr(entry, "runtime_data")', + ) + offenders: list[tuple[Path, str]] = [] + for py_file in pkg.rglob("*.py"): + text = py_file.read_text(encoding="utf-8") + for needle in forbidden: + if needle in text: + offenders.append((py_file.relative_to(pkg.parent.parent), needle)) + assert not offenders, f"Legacy patterns leaked back in: {offenders}"