From b885dfa5a803f869f6787ca1fd261b860a18d484 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 22 Aug 2023 10:29:16 +0200 Subject: [PATCH] Add preview to sensor group config and option flows (#83638) --- homeassistant/components/group/config_flow.py | 87 ++++++++- homeassistant/components/group/sensor.py | 24 ++- homeassistant/config_entries.py | 43 +++-- homeassistant/data_entry_flow.py | 19 ++ homeassistant/helpers/entity.py | 52 +++--- .../helpers/schema_config_entry_flow.py | 18 +- tests/components/cloud/test_repairs.py | 2 + .../components/config/test_config_entries.py | 6 + .../snapshots/test_config_flow.ambr | 5 + tests/components/group/test_config_flow.py | 172 ++++++++++++++++++ tests/components/hassio/test_repairs.py | 4 + tests/components/kitchen_sink/test_init.py | 1 + .../components/repairs/test_websocket_api.py | 1 + tests/components/subaru/test_config_flow.py | 1 + tests/test_config_entries.py | 93 ++++++++++ 15 files changed, 483 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 6cdc47f9e85..d8c983f83db 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -7,8 +7,10 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ENTITIES, CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -22,6 +24,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( from . import DOMAIN from .binary_sensor import CONF_ALL from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC +from .sensor import SensorGroup _STATISTIC_MEASURES = [ "min", @@ -36,15 +39,22 @@ _STATISTIC_MEASURES = [ async def basic_group_options_schema( - domain: str | list[str], handler: SchemaCommonFlowHandler + domain: str | list[str], handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" + if handler is None: + entity_selector = selector.selector( + {"entity": {"domain": domain, "multiple": True}} + ) + else: + entity_selector = entity_selector_without_own_entities( + cast(SchemaOptionsFlowHandler, handler.parent_handler), + selector.EntitySelectorConfig(domain=domain, multiple=True), + ) + return vol.Schema( { - vol.Required(CONF_ENTITIES): entity_selector_without_own_entities( - cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), - ), + vol.Required(CONF_ENTITIES): entity_selector, vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } ) @@ -96,7 +106,7 @@ SENSOR_OPTIONS = { async def sensor_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return ( @@ -184,6 +194,7 @@ CONFIG_FLOW = { "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, validate_user_input=set_group_type("sensor"), + preview="group_sensor", ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), @@ -202,7 +213,10 @@ OPTIONS_FLOW = { "media_player": SchemaFlowFormStep( partial(basic_group_options_schema, "media_player") ), - "sensor": SchemaFlowFormStep(partial(sensor_options_schema, "sensor")), + "sensor": SchemaFlowFormStep( + partial(sensor_options_schema, "sensor"), + preview="group_sensor", + ), "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), } @@ -241,6 +255,12 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): ) _async_hide_members(hass, options[CONF_ENTITIES], hidden_by) + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_preview_sensor) + def _async_hide_members( hass: HomeAssistant, members: list[str], hidden_by: er.RegistryEntryHider | None @@ -253,3 +273,56 @@ def _async_hide_members( if entity_id not in registry.entities: continue registry.async_update_entity(entity_id, hidden_by=hidden_by) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "group/sensor/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_preview_sensor( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Generate a preview.""" + if msg["flow_type"] == "config_flow": + validated = SENSOR_CONFIG_SCHEMA(msg["user_input"]) + ignore_non_numeric = False + name = validated["name"] + else: + validated = (await sensor_options_schema("sensor", None))(msg["user_input"]) + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError + ignore_non_numeric = validated[CONF_IGNORE_NON_NUMERIC] + name = config_entry.options["name"] + + @callback + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: + """Forward config entry state events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], {"state": state, "attributes": attributes} + ) + ) + + sensor = SensorGroup( + None, + name, + validated[CONF_ENTITIES], + ignore_non_numeric, + validated[CONF_TYPE], + None, + None, + None, + ) + sensor.hass = hass + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = sensor.async_start_preview( + async_preview_updated + ) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index d62447d9947..48175b55358 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,7 +1,7 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime import logging import statistics @@ -33,7 +33,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -303,6 +303,26 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1e3af81395a..d3ff741e3e6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1814,6 +1814,14 @@ class ConfigFlow(data_entry_flow.FlowHandler): class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" + def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: + """Return config entry or raise if not found.""" + entry = self.hass.config_entries.async_get_entry(config_entry_id) + if entry is None: + raise UnknownEntry(config_entry_id) + + return entry + async def async_create_flow( self, handler_key: str, @@ -1825,10 +1833,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): Entry_id and flow.handler is the same thing to map entry with flow. """ - entry = self.hass.config_entries.async_get_entry(handler_key) - if entry is None: - raise UnknownEntry(handler_key) - + entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) return handler.async_get_options_flow(entry) @@ -1853,6 +1858,14 @@ class OptionsFlowManager(data_entry_flow.FlowManager): result["result"] = True return result + async def _async_setup_preview(self, flow: data_entry_flow.FlowHandler) -> None: + """Set up preview for an option flow handler.""" + entry = self._async_get_config_entry(flow.handler) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in self._preview: + self._preview.add(entry.domain) + flow.async_setup_preview(self.hass) + class OptionsFlow(data_entry_flow.FlowHandler): """Base class for config options flows.""" @@ -2016,15 +2029,9 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: return hasattr(component, "async_remove_config_entry_device") -async def _async_get_flow_handler( +async def _load_integration( hass: HomeAssistant, domain: str, hass_config: ConfigType -) -> type[ConfigFlow]: - """Get a flow handler for specified domain.""" - - # First check if there is a handler registered for the domain - if domain in hass.config.components and (handler := HANDLERS.get(domain)): - return handler - +) -> None: try: integration = await loader.async_get_integration(hass, domain) except loader.IntegrationNotFound as err: @@ -2044,6 +2051,18 @@ async def _async_get_flow_handler( ) raise data_entry_flow.UnknownHandler + +async def _async_get_flow_handler( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> type[ConfigFlow]: + """Get a flow handler for specified domain.""" + + # First check if there is a handler registered for the domain + if domain in hass.config.components and (handler := HANDLERS.get(domain)): + return handler + + await _load_integration(hass, domain, hass_config) + if handler := HANDLERS.get(domain): return handler diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0408a24b2e..04876590d2b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -95,6 +95,7 @@ class FlowResult(TypedDict, total=False): last_step: bool | None menu_options: list[str] | dict[str, str] options: Mapping[str, Any] + preview: str | None progress_action: str reason: str required: bool @@ -135,6 +136,7 @@ class FlowManager(abc.ABC): ) -> None: """Initialize the flow manager.""" self.hass = hass + self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} self._handler_progress_index: dict[str, set[str]] = {} self._init_data_process_index: dict[type, set[str]] = {} @@ -395,6 +397,10 @@ class FlowManager(abc.ABC): flow.flow_id, flow.handler, err.reason, err.description_placeholders ) + # Setup the flow handler's preview if needed + if result.get("preview") is not None: + await self._async_setup_preview(flow) + if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] report( @@ -429,6 +435,12 @@ class FlowManager(abc.ABC): return result + async def _async_setup_preview(self, flow: FlowHandler) -> None: + """Set up preview for a flow handler.""" + if flow.handler not in self._preview: + self._preview.add(flow.handler) + flow.async_setup_preview(self.hass) + class FlowHandler: """Handle a data entry flow.""" @@ -504,6 +516,7 @@ class FlowHandler: errors: dict[str, str] | None = None, description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, + preview: str | None = None, ) -> FlowResult: """Return the definition of a form to gather user input.""" return FlowResult( @@ -515,6 +528,7 @@ class FlowHandler: errors=errors, description_placeholders=description_placeholders, last_step=last_step, # Display next or submit button in frontend + preview=preview, # Display preview component in frontend ) @callback @@ -635,6 +649,11 @@ class FlowHandler: def async_remove(self) -> None: """Notification that the flow has been removed.""" + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @callback def _create_abort_data( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9338346fc8b..29a944874ab 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -756,31 +756,10 @@ class Entity(ABC): return f"{device_name} {name}" if device_name else name @callback - def _async_write_ha_state(self) -> None: - """Write the state to the state machine.""" - if self._platform_state == EntityPlatformState.REMOVED: - # Polling returned after the entity has already been removed - return - - hass = self.hass - entity_id = self.entity_id + def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + """Calculate state string and attribute mapping.""" entry = self.registry_entry - if entry and entry.disabled_by: - if not self._disabled_reported: - self._disabled_reported = True - _LOGGER.warning( - ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" - ), - entity_id, - self.platform.platform_name, - ) - return - - start = timer() - attr = self.capability_attributes attr = dict(attr) if attr else {} @@ -818,6 +797,33 @@ class Entity(ABC): if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features + return (state, attr) + + @callback + def _async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self._platform_state == EntityPlatformState.REMOVED: + # Polling returned after the entity has already been removed + return + + hass = self.hass + entity_id = self.entity_id + + if (entry := self.registry_entry) and entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + ( + "Entity %s is incorrectly being triggered for updates while it" + " is disabled. This is a bug in the %s integration" + ), + entity_id, + self.platform.platform_name, + ) + return + + start = timer() + state, attr = self._async_generate_attributes() end = timer() if end - start > 0.4 and not self._slow_reported: diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 653594f2808..18d59f4f90d 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -78,6 +78,9 @@ class SchemaFlowFormStep(SchemaFlowStep): have priority over the suggested values. """ + preview: str | None = None + """Optional preview component.""" + @dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): @@ -237,6 +240,7 @@ class SchemaCommonFlowHandler: data_schema=data_schema, errors=errors, last_step=last_step, + preview=form_step.preview, ) async def _async_menu_step( @@ -271,7 +275,10 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): raise UnknownHandler return SchemaOptionsFlowHandler( - config_entry, cls.options_flow, cls.async_options_flow_finished + config_entry, + cls.options_flow, + cls.async_options_flow_finished, + cls.async_setup_preview, ) # Create an async_get_options_flow method @@ -285,6 +292,11 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """Initialize config flow.""" self._common_handler = SchemaCommonFlowHandler(self, self.config_flow, None) + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @classmethod @callback def async_supports_options_flow( @@ -357,6 +369,7 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options_flow: Mapping[str, SchemaFlowStep], async_options_flow_finished: Callable[[HomeAssistant, Mapping[str, Any]], None] | None = None, + async_setup_preview: Callable[[HomeAssistant], None] | None = None, ) -> None: """Initialize options flow. @@ -378,6 +391,9 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): types.MethodType(self._async_step(step), self), ) + if async_setup_preview: + setattr(self, "async_setup_preview", async_setup_preview) + @staticmethod def _async_step(step_id: str) -> Callable: """Generate a step handler.""" diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d010cac77ad..f83de408bcc 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -123,6 +123,7 @@ async def test_legacy_subscription_repair_flow( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -205,6 +206,7 @@ async def test_legacy_subscription_repair_flow_timeout( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 4684b4148b1..4239e031893 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -396,6 +396,7 @@ async def test_initialize_flow(hass: HomeAssistant, client) -> None: }, "errors": {"username": "Should be unique."}, "last_step": None, + "preview": None, } @@ -571,6 +572,7 @@ async def test_two_step_flow( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -647,6 +649,7 @@ async def test_continue_flow_unauth( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } hass_admin_user.groups = [] @@ -822,6 +825,7 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": {"enabled": "Set to true to be true"}, "errors": None, "last_step": None, + "preview": None, } @@ -917,6 +921,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -998,6 +1003,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 24cef3c349e..31925e2d626 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -9,6 +9,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -136,6 +137,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -150,6 +152,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -198,6 +201,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -212,6 +216,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 8202601fc18..a2c5ad64b1d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -446,3 +447,174 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + + +async def test_config_flow_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_sensors = ["sensor.input_one", "sensor.input_two"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "sensor" + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My sensor group", + "entities": input_sensors, + "type": "max", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + }, + "state": "unavailable", + } + + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": ["sensor.input_one", "sensor.input_two"], + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + "max_entity_id": "sensor.input_two", + }, + "state": "20.0", + } + + +async def test_option_flow_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": ["sensor.input_one", "sensor.input_two"], + "group_type": "sensor", + "hide_members": False, + "name": "My sensor group", + "type": "min", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + input_sensors = ["sensor.input_one", "sensor.input_two"] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_sensors, + "type": "min", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": ["sensor.input_one", "sensor.input_two"], + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + "min_entity_id": "sensor.input_one", + }, + "state": "10.0", + } + + +async def test_option_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": ["sensor.input_one", "sensor.input_two"], + "group_type": "sensor", + "hide_members": False, + "name": "My sensor group", + "type": "min", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + input_sensors = ["sensor.input_one", "sensor.input_two"] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_sensors, + "type": "min", + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 237c20a5272..21bf7e5b47a 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -84,6 +84,7 @@ async def test_supervisor_issue_repair_flow( "errors": None, "description_placeholders": {"reference": "/dev/sda1"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -292,6 +293,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -371,6 +373,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -580,6 +583,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "errors": None, "description_placeholders": {"components": "Home Assistant\n- test"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 88f2de5b394..ebd0f781d22 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -212,6 +212,7 @@ async def test_issues_created( "flow_id": ANY, "handler": DOMAIN, "last_step": None, + "preview": None, "step_id": "confirm", "type": "form", } diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index c82337b484f..6c9b51a7cf6 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -313,6 +313,7 @@ async def test_fix_issue( "flow_id": ANY, "handler": domain, "last_step": None, + "preview": None, "step_id": step, "type": "form", } diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index fc959fc434d..c3df10ed618 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -264,6 +264,7 @@ async def test_pin_form_init(pin_form) -> None: "step_id": "pin", "type": "form", "last_step": None, + "preview": None, } assert pin_form == expected diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3485162cbb3..f04f033b49f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1178,6 +1178,27 @@ async def test_entry_options_abort( ) +async def test_entry_options_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + class TestFlow: + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + with pytest.raises(config_entries.UnknownEntry): + await manager.options.async_create_flow( + "blah", context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -3919,3 +3940,75 @@ async def test_task_tracking(hass: HomeAssistant) -> None: hass.loop.call_soon(event.set) await entry._async_process_on_unload(hass) assert results == ["on_unload", "background", "normal"] + + +async def test_preview_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + preview_calls = [] + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_test1(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next") + + async def async_step_test2(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next", preview="test") + + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + preview_calls.append(None) + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + assert len(preview_calls) == 0 + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init("test", context={"source": "test1"}) + + assert len(preview_calls) == 0 + assert result["preview"] is None + + result = await manager.flow.async_init("test", context={"source": "test2"}) + + assert len(preview_calls) == 1 + assert result["preview"] == "test" + + +async def test_preview_not_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_user(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="user_confirm") + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + assert result["preview"] is None