diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 35c7d5d94ef..32054734e8e 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -1,7 +1,7 @@ """Component to allow setting text as platforms.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import asdict, dataclass from datetime import timedelta import logging import re @@ -20,6 +20,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( @@ -222,3 +223,47 @@ class TextEntity(Entity): async def async_set_value(self, value: str) -> None: """Change the value.""" await self.hass.async_add_executor_job(self.set_value, value) + + +@dataclass +class TextExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + native_value: str | None + native_min: int + native_max: int + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the text data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> TextExtraStoredData | None: + """Initialize a stored text state from a dict.""" + try: + return cls( + restored["native_value"], + restored["native_min"], + restored["native_max"], + ) + except KeyError: + return None + + +class RestoreText(TextEntity, RestoreEntity): + """Mixin class for restoring previous text state.""" + + @property + def extra_restore_state_data(self) -> TextExtraStoredData: + """Return text specific state data to be restored.""" + return TextExtraStoredData( + self.native_value, + self.native_min, + self.native_max, + ) + + async def async_get_last_text_data(self) -> TextExtraStoredData | None: + """Restore attributes.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return TextExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 1bbb3e1e1b0..dda746fe6a5 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -14,7 +14,11 @@ from homeassistant.components.text import ( _async_set_value, ) from homeassistant.const import MAX_LENGTH_STATE_STATE -from homeassistant.core import ServiceCall +from homeassistant.core import ServiceCall, State +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component + +from tests.common import mock_restore_cache_with_extra_data class MockTextEntity(TextEntity): @@ -95,10 +99,99 @@ async def test_text_set_value(hass): async def test_text_value_outside_bounds(hass): """Test text entity with value that is outside min and max.""" with pytest.raises(ValueError): - MockTextEntity( + _ = MockTextEntity( "hello world", native_min=2, native_max=5, pattern=r"[a-z]" ).state with pytest.raises(ValueError): - MockTextEntity( + _ = MockTextEntity( "hello world", native_min=15, native_max=20, pattern=r"[a-z]" ).state + + +RESTORE_DATA = { + "native_max": 5, + "native_min": 1, + # "mode": TextMode.TEXT, + # "pattern": r"[A-Za-z0-9]", + "native_value": "Hello", +} + + +async def test_restore_number_save_state( + hass, + hass_storage, + enable_custom_integrations, +): + """Test RestoreNumber.""" + platform = getattr(hass.components, "test.text") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreText( + name="Test", + native_max=5, + native_min=1, + native_value="Hello", + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "text", {"text": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await hass.async_stop() + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA + assert isinstance(extra_data["native_value"], str) + + +@pytest.mark.parametrize( + "native_max, native_min, native_value, native_value_type, extra_data", + [ + (5, 1, "Hello", str, RESTORE_DATA), + (255, 1, None, type(None), None), + (255, 1, None, type(None), {}), + (255, 1, None, type(None), {"beer": 123}), + (255, 1, None, type(None), {"native_value": {}}), + ], +) +async def test_restore_number_restore_state( + hass, + enable_custom_integrations, + hass_storage, + native_max, + native_min, + native_value, + native_value_type, + extra_data, +): + """Test RestoreNumber.""" + mock_restore_cache_with_extra_data(hass, ((State("text.test", ""), extra_data),)) + + platform = getattr(hass.components, "test.text") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreText( + native_max=native_max, + native_min=native_min, + name="Test", + native_value=None, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "text", {"text": {"platform": "test"}}) + await hass.async_block_till_done() + + assert hass.states.get(entity0.entity_id) + + assert entity0.native_max == native_max + assert entity0.native_min == native_min + assert entity0.mode == TextMode.TEXT + assert entity0.pattern is None + assert entity0.native_value == native_value + assert isinstance(entity0.native_value, native_value_type) diff --git a/tests/testing_config/custom_components/test/text.py b/tests/testing_config/custom_components/test/text.py new file mode 100644 index 00000000000..1430259e2e8 --- /dev/null +++ b/tests/testing_config/custom_components/test/text.py @@ -0,0 +1,86 @@ +""" +Provide a mock text platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.text import RestoreText, TextEntity, TextMode + +from tests.common import MockEntity + +UNIQUE_TEXT = "unique_text" + +ENTITIES = [] + + +class MockTextEntity(MockEntity, TextEntity): + """Mock text class.""" + + @property + def native_max(self): + """Return the native native_max.""" + return self._handle("native_max") + + @property + def native_min(self): + """Return the native native_min.""" + return self._handle("native_min") + + @property + def mode(self): + """Return the mode.""" + return self._handle("mode") + + @property + def pattern(self): + """Return the pattern.""" + return self._handle("pattern") + + @property + def native_value(self): + """Return the native value of this text.""" + return self._handle("native_value") + + def set_native_value(self, value: str) -> None: + """Change the selected option.""" + self._values["native_value"] = value + + +class MockRestoreText(MockTextEntity, RestoreText): + """Mock RestoreText class.""" + + async def async_added_to_hass(self) -> None: + """Restore native_*.""" + await super().async_added_to_hass() + if (last_text_data := await self.async_get_last_text_data()) is None: + return + self._values["native_max"] = last_text_data.native_max + self._values["native_min"] = last_text_data.native_min + self._values["native_value"] = last_text_data.native_value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockTextEntity( + name="test", + native_min=1, + native_max=5, + mode=TextMode.TEXT, + pattern=r"[A-Za-z0-9]", + unique_id=UNIQUE_TEXT, + native_value="Hello", + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES)