diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index 853f00f2dd5..bc077462784 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -123,7 +123,9 @@ async def async_setup_entry( ThermoProBluetoothSensorEntity, async_add_entities ) ) - entry.async_on_unload(coordinator.async_register_processor(processor)) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) class ThermoProBluetoothSensorEntity( diff --git a/tests/components/thermopro/test_sensor.py b/tests/components/thermopro/test_sensor.py index e9f234100b4..f42baa0b343 100644 --- a/tests/components/thermopro/test_sensor.py +++ b/tests/components/thermopro/test_sensor.py @@ -1,6 +1,15 @@ -"""Test the ThermoPro config flow.""" +"""Test the ThermoPro sensors.""" -from homeassistant.components.sensor import ATTR_STATE_CLASS +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, +) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntityDescription +import homeassistant.components.thermopro as thermopro_integration +from homeassistant.components.thermopro import sensor as thermopro_sensor from homeassistant.components.thermopro.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -125,3 +134,125 @@ async def test_sensors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +class CoordinatorStub: + """Coordinator stub for testing entity restoration behavior.""" + + instances: list["CoordinatorStub"] = [] + + def __init__( + self, + hass: HomeAssistant | None = None, + logger: MagicMock | None = None, + *, + address: str | None = None, + mode: MagicMock | None = None, + update_method: MagicMock | None = None, + ) -> None: + """Initialize coordinator stub with signature matching real coordinator.""" + # Track created instances to avoid direct hass.data access in tests + CoordinatorStub.instances.append(self) + self.calls: list[tuple[MagicMock, type | None]] = [] + self._saw_sensor_entity_description = False + self._restore_cb: MagicMock | None = None + + def async_register_processor( + self, processor: MagicMock, entity_description_cls: type | None = None + ) -> MagicMock: + """Register a processor and track if SensorEntityDescription was provided.""" + self.calls.append((processor, entity_description_cls)) + + if entity_description_cls is SensorEntityDescription: + self._saw_sensor_entity_description = True + + return lambda: None + + def async_start(self) -> MagicMock: + """Return a no-op unsub function for start lifecycle.""" + return lambda: None + + def trigger_restore_from_test(self) -> None: + """Trigger restoration callback if available.""" + if self._saw_sensor_entity_description and self._restore_cb: + self._restore_cb([]) + + def set_restore_callback(self, callback: MagicMock) -> None: + """Set the callback used to restore entities during the test.""" + self._restore_cb = callback + + +async def test_thermopro_restores_entities_on_restart_behavior( + hass: HomeAssistant, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test that entities are restored on restart via SensorEntityDescription.""" + + add_entities_callbacks: list[MagicMock] = [] + + orig_add_listener = PassiveBluetoothDataProcessor.async_add_entities_listener + + def wrapped_add_listener( + self: PassiveBluetoothDataProcessor, + entity_cls: type, + add_entities: MagicMock, + ) -> MagicMock: + add_entities_callbacks.append(add_entities) + return orig_add_listener(self, entity_cls, add_entities) + + monkeypatch.setattr( + PassiveBluetoothDataProcessor, + "async_add_entities_listener", + wrapped_add_listener, + ) + + first_called = {"v": False} + second_called = {"v": False} + + def add_entities_first(entities: list) -> None: + first_called["v"] = True + + def add_entities_second(entities: list) -> None: + second_called["v"] = True + + # Patch the integration to avoid platform forwarding and use the coordinator stub + monkeypatch.setattr(thermopro_integration, "PLATFORMS", []) + monkeypatch.setattr( + thermopro_integration, "PassiveBluetoothProcessorCoordinator", CoordinatorStub + ) + # Ensure a clean slate for stub instance tracking + CoordinatorStub.instances.clear() + + # First setup using real config entry setup to populate hass.data + entry1 = MockConfigEntry(domain=DOMAIN, unique_id="00:11:22:33:44:55") + entry1.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + + # Manually set up sensor platform with our callback + await thermopro_sensor.async_setup_entry(hass, entry1, add_entities_first) + await hass.async_block_till_done() + + coord = CoordinatorStub.instances[0] + assert coord.calls, "Processor was not registered on first setup" + assert not first_called["v"] + + # Second setup (simulating restart) + entry2 = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:FF") + entry2.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + await thermopro_sensor.async_setup_entry(hass, entry2, add_entities_second) + await hass.async_block_till_done() + + assert add_entities_callbacks, "No add_entities callback was registered" + coord2 = CoordinatorStub.instances[1] + coord2.set_restore_callback(add_entities_callbacks[-1]) + + coord2.trigger_restore_from_test() + await hass.async_block_till_done() + + assert second_called["v"], ( + "ThermoPro did not trigger restoration on startup. " + "Ensure async_register_processor(processor, SensorEntityDescription) is used." + )