diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 29f5d623bbcbe9..b86a7c767fdf26 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.3.1"], + "requirements": ["androidtvremote2==0.3.2"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index d711e0bb810335..579b2116a4b7cf 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -7,7 +7,11 @@ import caldav -from homeassistant.components.calendar import CalendarEvent, extract_offset +from homeassistant.components.calendar import ( + CalendarEvent, + CalendarEventStatus, + extract_offset, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -23,6 +27,24 @@ OFFSET = "!!" +def _get_status(vevent: caldav.CalendarObjectResource) -> CalendarEventStatus | None: + """Return the rfc5545 STATUS of a VEVENT, if a calendar entity reports it. + + Anything outside the supported set is dropped rather than passed on, which + covers both the cancelled status a calendar entity does not report and the + iana-tokens and x-names that rfc5545 also permits here: reporting no status + at all is closer to the truth than reporting one the consumer cannot + interpret. + """ + if (value := get_attr_value(vevent, "status")) is None: + return None + try: + return CalendarEventStatus(value.lower()) + except ValueError: + _LOGGER.debug("Ignoring unsupported event status %s", value) + return None + + class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" @@ -86,6 +108,7 @@ def _get_events( if (v := get_attr_value(vevent, "recurrence_id")) is not None else None ), + status=_get_status(vevent), ) ) @@ -194,6 +217,7 @@ def _get_next_event( if (v := get_attr_value(vevent, "recurrence_id")) is not None else None ), + status=_get_status(vevent), ) return next_event, offset diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 95e2a3bfea75d6..f609d02e25cea3 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -69,6 +69,7 @@ LIST_EVENT_FIELDS, CalendarEntityFeature, CalendarEntityStateAttribute, + CalendarEventStatus, ) # mypy: disallow-any-generics @@ -379,6 +380,7 @@ class CalendarEvent: uid: str | None = None recurrence_id: str | None = None rrule: str | None = None + status: CalendarEventStatus | None = None @property def start_datetime_local(self) -> datetime.datetime: diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index df0c43b0c73a94..afdb4dd321cf91 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -33,6 +33,22 @@ class CalendarEntityFeature(IntFlag): UPDATE_EVENT = 4 +class CalendarEventStatus(StrEnum): + """Status of a calendar event. + + A subset of the statuses defined by the rfc5545 STATUS property: a calendar + entity does not return cancelled events, so that value is not represented + here. + + An event without a status is not the same as a confirmed event: it means + the calendar did not report one, either because the source does not + support it or because the integration does not read it yet. + """ + + CONFIRMED = "confirmed" + TENTATIVE = "tentative" + + # rfc5545 fields EVENT_UID = "uid" EVENT_START = "dtstart" @@ -43,6 +59,7 @@ class CalendarEntityFeature(IntFlag): EVENT_RECURRENCE_ID = "recurrence_id" EVENT_RECURRENCE_RANGE = "recurrence_range" EVENT_RRULE = "rrule" +EVENT_STATUS = "status" # Service call fields EVENT_START_DATE = "start_date" @@ -69,4 +86,5 @@ class CalendarEntityFeature(IntFlag): EVENT_SUMMARY, EVENT_DESCRIPTION, EVENT_LOCATION, + EVENT_STATUS, } diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 46c315c1b07c8c..13a48ab13ec673 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -113,6 +113,12 @@ def domain(self) -> str: """Domain that is providing the implementation.""" return DOMAIN + @property + @override + def service_domain(self) -> str: + """Domain of the service the tokens are for.""" + return self.service + @override async def async_generate_authorize_url(self, flow_id: str) -> str: """Generate a url for the user to authorize.""" diff --git a/homeassistant/components/duco/manifest.json b/homeassistant/components/duco/manifest.json index e38fcba8e34113..425385c24f9cd8 100644 --- a/homeassistant/components/duco/manifest.json +++ b/homeassistant/components/duco/manifest.json @@ -13,7 +13,7 @@ "iot_class": "local_polling", "loggers": ["duco_connectivity"], "quality_scale": "platinum", - "requirements": ["python-duco-connectivity==0.14.0"], + "requirements": ["python-duco-connectivity==0.15.0"], "zeroconf": [ { "name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*", diff --git a/homeassistant/components/flexit/__init__.py b/homeassistant/components/flexit/__init__.py index cdab796b93b8af..dd8ba1ca7ae4bb 100644 --- a/homeassistant/components/flexit/__init__.py +++ b/homeassistant/components/flexit/__init__.py @@ -12,7 +12,7 @@ from .const import CONF_BAUDRATE, CONF_UNIT, DEFAULT_PORT, TYPE_SERIAL from .coordinator import FlexitConfigEntry, FlexitDataCoordinator -_PLATFORMS: list[Platform] = [Platform.CLIMATE] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CLIMATE] def create_modbus_params( diff --git a/homeassistant/components/flexit/binary_sensor.py b/homeassistant/components/flexit/binary_sensor.py new file mode 100644 index 00000000000000..290a2a2b536ae1 --- /dev/null +++ b/homeassistant/components/flexit/binary_sensor.py @@ -0,0 +1,80 @@ +"""Binary sensor platform for the Flexit integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from flexit_modbus import Measurements + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import FlexitConfigEntry, FlexitDataCoordinator +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describe a Flexit binary sensor entity.""" + + value_fn: Callable[[Measurements], bool | None] + + +BINARY_SENSORS: tuple[FlexitBinarySensorEntityDescription, ...] = ( + FlexitBinarySensorEntityDescription( + key="filter_alarm", + translation_key="filter_alarm", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda measurements: measurements.filter_alarm, + ), + FlexitBinarySensorEntityDescription( + key="electric_heater_enabled", + translation_key="electric_heater_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda measurements: measurements.electric_heater_enabled, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FlexitConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Flexit binary sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + FlexitBinarySensor(coordinator, description) for description in BINARY_SENSORS + ) + + +class FlexitBinarySensor(FlexitEntity, BinarySensorEntity): + """Representation of a Flexit binary sensor.""" + + entity_description: FlexitBinarySensorEntityDescription + + def __init__( + self, + coordinator: FlexitDataCoordinator, + entity_description: FlexitBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + assert coordinator.config_entry is not None + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}-{entity_description.key}" + ) + + @property + @override + def is_on(self) -> bool | None: + """Return the binary sensor state.""" + return self.entity_description.value_fn(self.coordinator.device.measurements) diff --git a/homeassistant/components/flexit/const.py b/homeassistant/components/flexit/const.py index 27197757754b0e..59c0ad4cf0e571 100644 --- a/homeassistant/components/flexit/const.py +++ b/homeassistant/components/flexit/const.py @@ -11,4 +11,4 @@ TYPE_TCP = "tcp" TYPE_SERIAL = "serial" -DEFAULT_BAUDRATE = 57600 +DEFAULT_BAUDRATE = 9600 diff --git a/homeassistant/components/flexit/strings.json b/homeassistant/components/flexit/strings.json index 3f9b2b1f865670..787df090dc1be5 100644 --- a/homeassistant/components/flexit/strings.json +++ b/homeassistant/components/flexit/strings.json @@ -57,6 +57,16 @@ } } }, + "entity": { + "binary_sensor": { + "electric_heater_enabled": { + "name": "Electric heater enabled" + }, + "filter_alarm": { + "name": "Filter alarm" + } + } + }, "issues": { "deprecated_yaml_no_import": { "description": "Configuring Flexit using YAML is being removed.\n\nYour existing YAML configuration could not be automatically imported because the Modbus connection details are configured separately, in a `modbus:` hub, which is not accessible from the `climate` platform configuration.\n\nRemove the `flexit` configuration from your configuration.yaml file, then add the integration again from the Home Assistant UI, providing the Modbus connection details and unit ID of your Flexit unit.", diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 73bded645f3635..f8a46fc73dfdff 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -269,7 +269,7 @@ async def _update_method(self) -> dict[SolarNetId, Any]: values[f"mppt_{number}_current_dc"] = module.current values[f"mppt_{number}_voltage_dc"] = module.voltage values[f"mppt_{number}_power_dc"] = module.power - values[f"mppt_{number}_energy_dc"] = module.energy + values[f"mppt_{number}_energy"] = module.energy return self._as_device_data(values) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index cb245f9d719414..ad3831f3691427 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -324,12 +324,12 @@ def _modbus_mppt_descriptions( translation_placeholders={"mppt_no": str(mppt_no)}, ), FroniusSensorEntityDescription( - key=f"mppt_{mppt_no}_energy_dc", + key=f"mppt_{mppt_no}_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, invalid_when_falsy=True, - translation_key="modbus_mppt_energy_dc", + translation_key="modbus_mppt_energy", translation_placeholders={"mppt_no": str(mppt_no)}, ), ] diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index 93659cfcfd651b..f2ff3db4400b7d 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -264,8 +264,8 @@ "modbus_mppt_current_dc": { "name": "MPPT {mppt_no} DC current" }, - "modbus_mppt_energy_dc": { - "name": "MPPT {mppt_no} DC energy" + "modbus_mppt_energy": { + "name": "MPPT {mppt_no} energy" }, "modbus_mppt_power_dc": { "name": "MPPT {mppt_no} DC power" diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 217696dd138872..e9f134c4958fda 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -32,6 +32,7 @@ CalendarEntityDescription, CalendarEntityFeature, CalendarEvent, + CalendarEventStatus, extract_offset, is_offset_reached, ) @@ -535,6 +536,11 @@ def _get_calendar_event(event: Event) -> CalendarEvent: end=event.end.value, description=event.description, location=event.location, + # The Google API defaults an omitted status to confirmed, and gcal_sync + # applies that default, so this is never None. It drops cancelled + # events when building the timeline, so only the statuses a calendar + # entity reports reach here, already in lower case. + status=CalendarEventStatus(event.status.value), ) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index cd52e903c01603..f6ece70b46485f 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -21,6 +21,7 @@ CalendarEntity, CalendarEntityFeature, CalendarEvent, + CalendarEventStatus, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -228,6 +229,23 @@ def _parse_event(event: dict[str, Any]) -> Event: raise vol.Invalid("Error parsing event input fields") from err +def _get_status(event: Event) -> CalendarEventStatus | None: + """Return the status of an event, if a calendar entity reports that status. + + ical models the full rfc5545 set, which includes cancelled, and an imported + calendar can contain such an event. A calendar entity does not report a + cancelled status, so anything outside the supported set maps to no status. + ical's enum is a plain (str, Enum) rather than a StrEnum, so its value has + to be read explicitly. + """ + if event.status is None: + return None + try: + return CalendarEventStatus(event.status.value.lower()) + except ValueError: + return None + + def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" start: datetime | date @@ -252,4 +270,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, location=event.location, + status=_get_status(event), ) diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 8b0ea2e5dd45ba..947bbe08c3e457 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for Satel Integra alarm, using ETHM module.""" import asyncio -import logging from typing import override from satel_integra import AlarmState @@ -14,9 +13,15 @@ ) from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_ARM_HOME_MODE, CONF_PARTITION_NUMBER, SUBENTRY_TYPE_PARTITION +from .const import ( + CONF_ARM_HOME_MODE, + CONF_PARTITION_NUMBER, + DOMAIN, + SUBENTRY_TYPE_PARTITION, +) from .coordinator import SatelConfigEntry, SatelIntegraPartitionsCoordinator from .entity import SatelIntegraEntity @@ -32,8 +37,6 @@ AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING, } -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 0 @@ -118,8 +121,10 @@ def _read_alarm_state(self) -> AlarmControlPanelState: async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not code: - _LOGGER.debug("Code was empty or None") - return + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_alarm_access_code", + ) clear_alarm_necessary = ( self._attr_alarm_state == AlarmControlPanelState.TRIGGERED @@ -135,13 +140,9 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: @override async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - - if code: - await self._controller.arm(code, [self._device_number]) + await self._controller.arm(code, [self._device_number]) @override async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - - if code: - await self._controller.arm(code, [self._device_number], self._arm_home_mode) + await self._controller.arm(code, [self._device_number], self._arm_home_mode) diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 6534aab20a3f25..4f407ff604af89 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -203,6 +203,9 @@ "connection_initialization_failed": { "message": "[%key:component::satel_integra::config::error::connection_initialization_failed%]" }, + "missing_alarm_access_code": { + "message": "Cannot disarm the alarm panel because no user code was provided." + }, "missing_output_access_code": { "message": "Cannot control switchable outputs because no user code is configured for this Satel Integra entry. Configure a code in the integration options to enable output control." }, diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index b32d95db3af9b1..2ec12557c91cd4 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["python-technove==2.1.3"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/quality_scale.yaml b/homeassistant/components/technove/quality_scale.yaml new file mode 100644 index 00000000000000..348012bf68b72a --- /dev/null +++ b/homeassistant/components/technove/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide any actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide any actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: + status: exempt + comment: Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide any configuration options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration has a fixed single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 5c21c20346ae32..4682bbfa117891 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator, Sequence from typing import TYPE_CHECKING, Any, override -from aiohttp import ClientResponse, ClientResponseError, RequestInfo +from aiohttp import ClientError, ClientResponse, ClientResponseError, RequestInfo from multidict import MultiMapping from .util.event_type import EventType @@ -253,8 +253,20 @@ class ConfigEntryAuthFailed(IntegrationError): """Error to indicate that config entry could not authenticate.""" -class OAuth2TokenRequestError(ClientResponseError, HomeAssistantError): - """Error to indicate that the OAuth 2.0 flow could not refresh token.""" +class OAuth2TokenRequestBaseError(ConfigEntryNotReady): + """Base class for the errors a failed OAuth 2.0 token request raises. + + Catch this to handle every token request failure; the subclasses differ in + whether a status was received and what should happen to the config entry. + """ + + +class OAuth2TokenRequestError(ClientResponseError, OAuth2TokenRequestBaseError): + """Error to indicate that the OAuth 2.0 flow could not refresh token. + + Inherits ConfigEntryNotReady so setup retries without the integration having to + map it. Catch it explicitly to handle it differently. + """ def __init__( self, @@ -275,7 +287,7 @@ def __init__( message=message, headers=headers, ) - HomeAssistantError.__init__(self) + OAuth2TokenRequestBaseError.__init__(self) self.domain = domain self.translation_domain = "homeassistant" self.translation_key = "oauth2_helper_refresh_failed" @@ -283,7 +295,24 @@ def __init__( self.generate_message = True -class OAuth2TokenRequestTransientError(OAuth2TokenRequestError, ConfigEntryNotReady): +class OAuth2TokenRequestConnectionError(ClientError, OAuth2TokenRequestBaseError): + """Recoverable error to indicate the token request yielded no usable token. + + Covers a request that never got a response and one whose response could not + be used, neither of which has a status to tell the causes apart. + """ + + def __init__(self, *, domain: str) -> None: + """Initialize OAuth2TokenRequestConnectionError.""" + OAuth2TokenRequestBaseError.__init__(self) + self.domain = domain + self.translation_domain = "homeassistant" + self.translation_key = "oauth2_helper_refresh_transient" + self.translation_placeholders = {"domain": domain} + self.generate_message = True + + +class OAuth2TokenRequestTransientError(OAuth2TokenRequestError): """Recoverable error to indicate flow could not refresh token. Inherits ConfigEntryNotReady so setup retries without the integration having to diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index ace26466c4513d..95926ea7b7643b 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -17,7 +17,7 @@ import logging import secrets import time -from typing import Any, cast, override +from typing import Any, NoReturn, cast, override from aiohttp import ClientError, ClientResponseError, client, hdrs, web from habluetooth import BluetoothServiceInfoBleak @@ -30,6 +30,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback from homeassistant.exceptions import ( ImplementationUnavailableError, + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -105,6 +106,28 @@ ) +def _raise_mapped_token_error(err: ClientError, domain: str) -> NoReturn: + """Re-raise a failed token request as the matching OAuth2 token error.""" + if not isinstance(err, ClientResponseError): + # Nothing was received, so there is no status to tell the causes apart. + _LOGGER.debug("Token request for %s got no response: %s", domain, err) + raise OAuth2TokenRequestConnectionError(domain=domain) from err + + kwargs: dict[str, Any] = { + "request_info": err.request_info, + "history": err.history, + "status": err.status, + "message": err.message, + "headers": err.headers, + "domain": domain, + } + if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: + raise OAuth2TokenRequestTransientError(**kwargs) from err + if 400 <= err.status <= 499: + raise OAuth2TokenRequestReauthError(**kwargs) from err + raise OAuth2TokenRequestError(**kwargs) from err + + @callback def async_get_redirect_uri(hass: HomeAssistant) -> str: """Return the redirect uri.""" @@ -163,11 +186,30 @@ async def async_resolve_external_data(self, external_data: Any) -> dict: config entry data. """ + @property + def service_domain(self) -> str: + """Domain of the service the tokens are for. + + Defaults to the implementation itself, but an implementation that obtains + tokens on behalf of other integrations has to name the one it serves. + """ + return self.domain + async def async_refresh_token(self, token: dict) -> dict: """Refresh a token and update expires info.""" - new_token = await self._async_refresh_token(token) + try: + new_token = await self._async_refresh_token(token) + except OAuth2TokenRequestError, OAuth2TokenRequestConnectionError: + raise + except ClientError as err: + # Implementations that issue their own token request may not map their + # failures, so callers would see a raw aiohttp error instead. + _raise_mapped_token_error(err, self.service_domain) # Force int for non-compliant oauth2 providers - new_token["expires_in"] = int(new_token["expires_in"]) + try: + new_token["expires_in"] = int(new_token["expires_in"]) + except (KeyError, TypeError, ValueError) as err: + raise OAuth2TokenRequestConnectionError(domain=self.service_domain) from err new_token["expires_at"] = time.time() + new_token["expires_in"] return new_token @@ -268,6 +310,11 @@ async def _async_refresh_token(self, token: dict) -> dict: } ) + # Merging a response without one would keep the stale access token while + # extending its expiry, so the session would never recover. + if not new_token.get("access_token"): + raise OAuth2TokenRequestConnectionError(domain=self.service_domain) + return {**token, **new_token} async def _token_request(self, data: dict) -> dict: @@ -306,38 +353,13 @@ async def _token_request(self, data: dict) -> dict: detail, ) resp.raise_for_status() + return cast(dict, await resp.json()) except ClientResponseError as err: - if err.status == HTTPStatus.TOO_MANY_REQUESTS or 500 <= err.status <= 599: - # Recoverable error - raise OAuth2TokenRequestTransientError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - if 400 <= err.status <= 499: - # Non-recoverable error - raise OAuth2TokenRequestReauthError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - - raise OAuth2TokenRequestError( - request_info=err.request_info, - history=err.history, - status=err.status, - message=err.message, - headers=err.headers, - domain=self._domain, - ) from err - - return cast(dict, await resp.json()) + _raise_mapped_token_error(err, self.service_domain) + except ClientError as err: + # Bare TimeoutError is left alone so an enclosing asyncio.timeout still + # aborts with oauth_timeout; aiohttp's own timeouts are ClientErrors. + _raise_mapped_token_error(err, self.service_domain) class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation): @@ -844,6 +866,15 @@ async def async_ensure_token_valid(self) -> None: self.config_entry.async_start_reauth_if_available(self.hass) raise + # Checked before storing, so reads can trust what is on the entry. + if any( + new_token.get(field) in (None, "") + for field in ("access_token", "expires_at") + ): + raise OAuth2TokenRequestConnectionError( + domain=self.implementation.service_domain + ) + self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, "token": new_token} ) diff --git a/requirements_all.txt b/requirements_all.txt index 009967e77a77a4..4b38a9ba6b7a4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -531,7 +531,7 @@ amcrest==1.9.9 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.3.1 +androidtvremote2==0.3.2 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -2702,7 +2702,7 @@ python-digitalocean==1.13.2 python-dropbox-api==0.1.4 # homeassistant.components.duco -python-duco-connectivity==0.14.0 +python-duco-connectivity==0.15.0 # homeassistant.components.ecobee python-ecobee-api==0.4.1 diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 8a8171c148a599..ce03ea24afa616 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -881,7 +881,6 @@ class Rule: "tasmota", "tautulli", "tcp", - "technove", "ted5000", "telegram", "tellduslive", @@ -1841,7 +1840,6 @@ class Rule: "tasmota", "tautulli", "tcp", - "technove", "ted5000", "telegram", "tellduslive", diff --git a/script/hassfest/quality_scale_validation/test_before_setup.py b/script/hassfest/quality_scale_validation/test_before_setup.py index 02eef8dc14b3cd..1e19c80a11cc6b 100644 --- a/script/hassfest/quality_scale_validation/test_before_setup.py +++ b/script/hassfest/quality_scale_validation/test_before_setup.py @@ -14,6 +14,13 @@ "ConfigEntryError", } +# Helpers that raise one of the above on the caller's behalf, so an integration +# awaiting them satisfies the rule without repeating the mapping itself. +_VALID_AWAITED_CALLS = { + "async_config_entry_first_refresh", + "async_ensure_token_valid", +} + def _get_exception_name(expression: ast.expr) -> str: """Get the name of the exception being raised.""" @@ -58,17 +65,20 @@ def _raises_exception(integration: Integration) -> bool: return False -def _calls_first_refresh(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: - """Check that a async_config_entry_first_refresh within `async_setup_entry`.""" - for node in ast.walk(async_setup_entry_function): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "async_config_entry_first_refresh" - ): - return True +def _awaits_raising_helper(async_setup_entry_function: ast.AsyncFunctionDef) -> bool: + """Check that `async_setup_entry` awaits a helper that raises on its behalf. - return False + The call only has to sit somewhere inside an await, so gathering several of + them still counts, while an unawaited call does not. + """ + return any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in _VALID_AWAITED_CALLS + for await_node in ast.walk(async_setup_entry_function) + if isinstance(await_node, ast.Await) + for node in ast.walk(await_node) + ) def _get_setup_entry_function(module: ast.Module) -> ast.AsyncFunctionDef | None: @@ -90,6 +100,8 @@ def validate( if not (async_setup_entry := _get_setup_entry_function(init)): return [f"Could not find `async_setup_entry` in {init_file}"] - if not (_calls_first_refresh(async_setup_entry) or _raises_exception(integration)): + if not ( + _awaits_raising_helper(async_setup_entry) or _raises_exception(integration) + ): return [f"Integration does not raise one of {_VALID_EXCEPTIONS}"] return None diff --git a/tests/components/air_quality/test_condition.py b/tests/components/air_quality/test_condition.py index 7b10ffbde641ef..66ac4c5d887b91 100644 --- a/tests/components/air_quality/test_condition.py +++ b/tests/components/air_quality/test_condition.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.air_quality.condition import CONDITIONS from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,9 +18,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, assert_numerical_condition_unit_conversion, parametrize_condition_states_all, parametrize_condition_states_any, @@ -69,6 +72,29 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: } +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_gas_detected": TargetSupport.STANDARD, + "is_gas_cleared": TargetSupport.STANDARD, + "is_co_detected": TargetSupport.STANDARD, + "is_co_cleared": TargetSupport.STANDARD, + "is_smoke_detected": TargetSupport.STANDARD, + "is_smoke_cleared": TargetSupport.STANDARD, + "is_co_value": TargetSupport.STANDARD, + "is_ozone_value": TargetSupport.STANDARD, + "is_voc_value": TargetSupport.STANDARD, + "is_voc_ratio_value": TargetSupport.STANDARD, + "is_no_value": TargetSupport.STANDARD, + "is_no2_value": TargetSupport.STANDARD, + "is_so2_value": TargetSupport.STANDARD, + "is_co2_value": TargetSupport.STANDARD, + "is_pm1_value": TargetSupport.STANDARD, + "is_pm25_value": TargetSupport.STANDARD, + "is_pm4_value": TargetSupport.STANDARD, + "is_pm10_value": TargetSupport.STANDARD, + "is_n2o_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -114,6 +140,11 @@ async def test_air_quality_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/air_quality/test_trigger.py b/tests/components/air_quality/test_trigger.py index 70b3e0ade47991..27b76d58f9975e 100644 --- a/tests/components/air_quality/test_trigger.py +++ b/tests/components/air_quality/test_trigger.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.air_quality.trigger import TRIGGERS from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -18,12 +19,14 @@ from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, @@ -72,6 +75,42 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "gas_detected": TargetSupport.STANDARD, + "gas_cleared": TargetSupport.STANDARD, + "co_detected": TargetSupport.STANDARD, + "co_cleared": TargetSupport.STANDARD, + "smoke_detected": TargetSupport.STANDARD, + "smoke_cleared": TargetSupport.STANDARD, + "co_changed": TargetSupport.STANDARD, + "co_crossed_threshold": TargetSupport.STANDARD, + "ozone_changed": TargetSupport.STANDARD, + "ozone_crossed_threshold": TargetSupport.STANDARD, + "voc_changed": TargetSupport.STANDARD, + "voc_crossed_threshold": TargetSupport.STANDARD, + "voc_ratio_changed": TargetSupport.STANDARD, + "voc_ratio_crossed_threshold": TargetSupport.STANDARD, + "no_changed": TargetSupport.STANDARD, + "no_crossed_threshold": TargetSupport.STANDARD, + "no2_changed": TargetSupport.STANDARD, + "no2_crossed_threshold": TargetSupport.STANDARD, + "so2_changed": TargetSupport.STANDARD, + "so2_crossed_threshold": TargetSupport.STANDARD, + "co2_changed": TargetSupport.STANDARD, + "co2_crossed_threshold": TargetSupport.STANDARD, + "pm1_changed": TargetSupport.STANDARD, + "pm1_crossed_threshold": TargetSupport.STANDARD, + "pm25_changed": TargetSupport.STANDARD, + "pm25_crossed_threshold": TargetSupport.STANDARD, + "pm4_changed": TargetSupport.STANDARD, + "pm4_crossed_threshold": TargetSupport.STANDARD, + "pm10_changed": TargetSupport.STANDARD, + "pm10_crossed_threshold": TargetSupport.STANDARD, + "n2o_changed": TargetSupport.STANDARD, + "n2o_crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -126,6 +165,11 @@ async def test_air_quality_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py index ba1df56fbbbc73..c72b3d67c0a478 100644 --- a/tests/components/alarm_control_panel/test_condition.py +++ b/tests/components/alarm_control_panel/test_condition.py @@ -8,14 +8,17 @@ AlarmControlPanelEntityFeature, AlarmControlPanelState, ) +from homeassistant.components.alarm_control_panel.condition import CONDITIONS from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -30,6 +33,17 @@ async def target_alarm_control_panels(hass: HomeAssistant) -> dict[str, list[str return await target_entities(hass, "alarm_control_panel") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_armed": TargetSupport.STANDARD, + "is_armed_away": TargetSupport.STANDARD, + "is_armed_home": TargetSupport.STANDARD, + "is_armed_night": TargetSupport.STANDARD, + "is_armed_vacation": TargetSupport.STANDARD, + "is_disarmed": TargetSupport.STANDARD, + "is_triggered": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -59,6 +73,11 @@ async def test_alarm_control_panel_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("alarm_control_panel"), diff --git a/tests/components/alarm_control_panel/test_trigger.py b/tests/components/alarm_control_panel/test_trigger.py index fdeb8675edcb43..6f7582c53b644a 100644 --- a/tests/components/alarm_control_panel/test_trigger.py +++ b/tests/components/alarm_control_panel/test_trigger.py @@ -8,15 +8,18 @@ AlarmControlPanelEntityFeature, AlarmControlPanelState, ) +from homeassistant.components.alarm_control_panel.trigger import TRIGGERS from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_target_entities, parametrize_trigger_states, @@ -30,6 +33,17 @@ async def target_alarm_control_panels(hass: HomeAssistant) -> dict[str, list[str return await target_entities(hass, "alarm_control_panel") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "armed": TargetSupport.STANDARD, + "armed_away": TargetSupport.STANDARD, + "armed_home": TargetSupport.STANDARD, + "armed_night": TargetSupport.STANDARD, + "armed_vacation": TargetSupport.STANDARD, + "disarmed": TargetSupport.STANDARD, + "triggered": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -59,6 +73,11 @@ async def test_alarm_control_panel_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("alarm_control_panel"), diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py index b0aecd36ad84c7..5049de3ef015f9 100644 --- a/tests/components/assist_satellite/test_condition.py +++ b/tests/components/assist_satellite/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.assist_satellite.condition import CONDITIONS from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,14 @@ async def target_assist_satellites(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "assist_satellite") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_idle": TargetSupport.STANDARD, + "is_listening": TargetSupport.STANDARD, + "is_processing": TargetSupport.STANDARD, + "is_responding": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -52,6 +63,11 @@ async def test_assist_satellite_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("assist_satellite"), diff --git a/tests/components/assist_satellite/test_trigger.py b/tests/components/assist_satellite/test_trigger.py index f911268c5909e9..fd5be0591f1974 100644 --- a/tests/components/assist_satellite/test_trigger.py +++ b/tests/components/assist_satellite/test_trigger.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.assist_satellite.entity import AssistSatelliteState +from homeassistant.components.assist_satellite.trigger import TRIGGERS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_target_entities, parametrize_trigger_states, @@ -26,6 +29,14 @@ async def target_assist_satellites(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "assist_satellite") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "idle": TargetSupport.STANDARD, + "listening": TargetSupport.STANDARD, + "processing": TargetSupport.STANDARD, + "responding": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -52,6 +63,11 @@ async def test_assist_satellite_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("assist_satellite"), diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index b5de311e340b43..41dfacf5d5ac64 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.battery.condition import CONDITIONS from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -15,9 +16,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -48,6 +51,15 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _LEVEL_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_low": TargetSupport.STANDARD, + "is_not_low": TargetSupport.STANDARD, + "is_charging": TargetSupport.STANDARD, + "is_not_charging": TargetSupport.STANDARD, + "is_level": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -75,6 +87,11 @@ async def test_battery_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/battery/test_trigger.py b/tests/components/battery/test_trigger.py index 0f2505f309e8d9..e8df6411edee35 100644 --- a/tests/components/battery/test_trigger.py +++ b/tests/components/battery/test_trigger.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.battery.trigger import TRIGGERS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -15,11 +16,13 @@ from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, @@ -48,6 +51,16 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _LEVEL_CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "became_low": TargetSupport.STANDARD, + "no_longer_low": TargetSupport.STANDARD, + "started_charging": TargetSupport.STANDARD, + "stopped_charging": TargetSupport.STANDARD, + "level_changed": TargetSupport.STANDARD, + "level_crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -76,6 +89,11 @@ async def test_battery_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/button/test_trigger.py b/tests/components/button/test_trigger.py index 4b0634bd39acf8..80656af27bf0ee 100644 --- a/tests/components/button/test_trigger.py +++ b/tests/components/button/test_trigger.py @@ -4,13 +4,16 @@ import pytest +from homeassistant.components.button.trigger import TRIGGERS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -25,6 +28,11 @@ async def target_entities_indirect( return await target_entities(hass, request.param) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "pressed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -48,6 +56,11 @@ async def test_button_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ( "target_entities_indirect", diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index cefbffd0a84995..2df5e5ea53100f 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -381,6 +381,44 @@ def _mock_calendar(name: str, supported_components: list[str] | None = None) -> return calendar +async def _get_api_events_for_vevent( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + vevent: str, + uid: str, +) -> list[dict[str, Any]]: + """Set up a calendar holding a single VEVENT and return its events from the API. + + Used by tests that assert on how one specific VEVENT property is parsed, + which the shared EVENTS series cannot express: it is fixed at 18 entries + that other tests count on. + """ + calendar = Mock() + calendar.name = "Example" + calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) + calendar.search = MagicMock( + return_value=[Event(None, "0.ics", vevent, calendar, uid)] + ) + + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = [ + calendar + ] + assert await async_setup_component( + hass, "calendar", {"calendar": CALDAV_CONFIG} + ) + await hass.async_block_till_done() + + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start=2017-11-27&end=2017-11-28" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + @pytest.fixture(name="config") def mock_config() -> dict[str, Any]: """Fixture to provide calendar configuration.yaml.""" @@ -1078,6 +1116,7 @@ async def test_get_events_custom_calendars( "uid": "0", "recurrence_id": None, "rrule": None, + "status": None, } ] @@ -1101,41 +1140,57 @@ async def test_get_events_with_recurrence_id( DESCRIPTION:This occurrence was moved END:VEVENT END:VCALENDAR""" - calendar = Mock() - calendar.name = "Example" - calendar.get_supported_components = MagicMock(return_value=["VEVENT"]) - calendar.search = MagicMock( - return_value=[ - Event( - None, "0.ics", vevent_with_recurrence_id, calendar, "original-event-uid" - ) - ] + events = await _get_api_events_for_vevent( + hass, hass_client, vevent_with_recurrence_id, "original-event-uid" ) - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient" - ) as mock_client: - mock_client.return_value.principal.return_value.calendars.return_value = [ - calendar - ] - assert await async_setup_component( - hass, "calendar", {"calendar": CALDAV_CONFIG} - ) - await hass.async_block_till_done() - - client = await hass_client() - response = await client.get( - f"/api/calendars/{TEST_ENTITY}?start=2017-11-27&end=2017-11-28" - ) - assert response.status == HTTPStatus.OK - events = await response.json() - assert len(events) == 1 assert events[0]["uid"] == "original-event-uid" assert events[0]["recurrence_id"] == "2017-11-27 17:00:00+00:00" assert events[0]["summary"] == "Modified occurrence" +ICS_WITH_STATUS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:status-event-uid +DTSTAMP:20171125T000000Z +DTSTART:20171127T170000Z +DTEND:20171127T180000Z +SUMMARY:This is an event with a status +LOCATION:Hamburg +DESCRIPTION:Surprisingly rainy +STATUS:{status} +END:VEVENT +END:VCALENDAR""" + + +@pytest.mark.parametrize( + ("status", "expected_status"), + [ + pytest.param("TENTATIVE", "tentative", id="tentative"), + pytest.param("CONFIRMED", "confirmed", id="confirmed"), + pytest.param("Tentative", "tentative", id="mixed_case"), + pytest.param("CANCELLED", None, id="cancelled_is_not_reported"), + pytest.param("X-VENDOR-SPECIFIC", None, id="unsupported_value"), + ], +) +async def test_get_events_with_status( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + status: str, + expected_status: str | None, +) -> None: + """Test that the rfc5545 STATUS property is populated from VEVENT data.""" + events = await _get_api_events_for_vevent( + hass, hass_client, ICS_WITH_STATUS.format(status=status), "status-event-uid" + ) + + assert len(events) == 1 + assert events[0]["status"] == expected_status + + @pytest.mark.parametrize( ("calendars"), [ diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py index c08e4459e6d139..3b088e5f79e47c 100644 --- a/tests/components/calendar/test_condition.py +++ b/tests/components/calendar/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.calendar.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,11 @@ async def target_calendars(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "calendar") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_event_active": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -48,6 +56,11 @@ async def test_calendar_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("calendar"), diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 406753bc82dcb1..7770edc0174e3b 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.climate.condition import CONDITIONS from homeassistant.components.climate.const import ( ATTR_HUMIDITY, ATTR_HVAC_ACTION, @@ -19,9 +20,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, assert_numerical_condition_unit_conversion, other_states, parametrize_condition_states_all, @@ -48,6 +51,18 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: } +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_hvac_mode": TargetSupport.STANDARD, + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, + "is_cooling": TargetSupport.STANDARD, + "is_drying": TargetSupport.STANDARD, + "is_heating": TargetSupport.STANDARD, + "is_target_humidity": TargetSupport.STANDARD, + "is_target_temperature": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -78,6 +93,11 @@ async def test_climate_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("climate"), diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 5d32c2f6ed87a6..9b9cc69c7504c7 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -12,7 +12,7 @@ HVACAction, HVACMode, ) -from homeassistant.components.climate.trigger import CONF_HVAC_MODE +from homeassistant.components.climate.trigger import CONF_HVAC_MODE, TRIGGERS from homeassistant.const import ( ATTR_TEMPERATURE, CONF_ENTITY_ID, @@ -24,11 +24,13 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, @@ -54,6 +56,20 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "hvac_mode_changed": TargetSupport.STANDARD, + "started_cooling": TargetSupport.STANDARD, + "started_drying": TargetSupport.STANDARD, + "target_humidity_changed": TargetSupport.STANDARD, + "target_humidity_crossed_threshold": TargetSupport.STANDARD, + "target_temperature_changed": TargetSupport.STANDARD, + "target_temperature_crossed_threshold": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, + "started_heating": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -96,6 +112,11 @@ async def test_climate_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger", "trigger_options", "expected_result"), [ diff --git a/tests/components/cloud/test_account_link.py b/tests/components/cloud/test_account_link.py index 56f1e461195827..43223228aef01f 100644 --- a/tests/components/cloud/test_account_link.py +++ b/tests/components/cloud/test_account_link.py @@ -6,7 +6,7 @@ from time import time from unittest.mock import AsyncMock, Mock, patch -from aiohttp import ClientResponseError, RequestInfo +from aiohttp import ClientError, ClientResponseError, RequestInfo import pytest from yarl import URL @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ( + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -304,3 +305,24 @@ async def test_refresh_token_error( assert exc_info.value.status == status assert exc_info.value.domain == "test" + + +async def test_refresh_token_connection_error(hass: HomeAssistant) -> None: + """Test a failure without a response reports the service, not the cloud domain.""" + hass.data[DATA_CLOUD] = None + impl = account_link.CloudOAuth2Implementation(hass, "test") + + with ( + patch( + "hass_nabucasa.account_link.async_fetch_access_token", + side_effect=ClientError("Cannot connect"), + ), + pytest.raises(OAuth2TokenRequestConnectionError) as exc_info, + ): + await impl.async_refresh_token( + {"refresh_token": "mock-refresh", "access_token": "mock-access"} + ) + + assert impl.domain == "cloud" + assert exc_info.value.domain == "test" + assert exc_info.value.translation_placeholders == {"domain": "test"} diff --git a/tests/components/collection_image/conftest.py b/tests/components/collection_image/conftest.py index 3373b34f7da149..dbe3345628b093 100644 --- a/tests/components/collection_image/conftest.py +++ b/tests/components/collection_image/conftest.py @@ -1,82 +1,149 @@ """Fixtures for the Collection Image integration tests.""" +from collections.abc import Iterator +from dataclasses import dataclass, field from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.collection_image.const import DOMAIN -from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass from homeassistant.components.media_source import BrowseMediaSource, PlayMedia +from homeassistant.core import HomeAssistant -from .const import TEST_IMAGE +from .const import ( + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_IMAGE_URI_1, + TEST_IMAGE, +) +from .helpers import directory, image from tests.common import MockConfigEntry +@dataclass +class MediaSourceState: + """Configurable responses for the mocked media-source API.""" + + browse_results: dict[str, BrowseMediaSource] = field(default_factory=dict) + browse_exceptions: dict[str, Exception] = field(default_factory=dict) + resolve_results: dict[str, PlayMedia] = field(default_factory=dict) + resolve_exceptions: dict[str, Exception] = field(default_factory=dict) + + +@dataclass(frozen=True) +class MediaSourceMocks: + """Mocks installed for calls to the media-source API.""" + + config_flow_browse: AsyncMock + image_browse: AsyncMock + resolve: AsyncMock + + @pytest.fixture def config_entry() -> MockConfigEntry: """Return the default collection-image config entry.""" return MockConfigEntry( + domain=DOMAIN, + title="Random Image", data={ "media": { - "media_content_id": "media-source://mymedia", + "media_content_id": MOCK_MEDIA_DIR_URI_1, "media_content_type": "", }, }, - domain=DOMAIN, - title="Random Image", ) @pytest.fixture -def browse_media_result() -> BrowseMediaSource: - """Return a default collection containing one image.""" - return BrowseMediaSource( - domain=None, - identifier=None, - media_class="", - media_content_type="", - title="", - can_play=False, - can_expand=True, - children=[ - BrowseMedia( - media_class=MediaClass.MUSIC, - media_content_id="media-source://mymedia/music", - media_content_type="audio/mp3", - title="a music track", - can_play=True, - can_expand=False, +def media_source_state() -> MediaSourceState: + """Return default configurable responses for the media-source mock.""" + return MediaSourceState( + browse_results={ + MOCK_MEDIA_DIR_URI_1: directory( + "My pictures", + BrowseMedia( + media_class=MediaClass.MUSIC, + media_content_id="media-source://mymedia/music", + media_content_type="audio/mp3", + title="a music track", + can_play=True, + can_expand=False, + ), + image(MOCK_MEDIA_IMAGE_URI_1), ), - BrowseMedia( - media_class=MediaClass.IMAGE, - media_content_id="media-source://mymedia/photo", - media_content_type="image/png", - title="a picture", - can_play=True, - can_expand=False, + MOCK_MEDIA_DIR_URI_EMPTY: directory("Empty folder"), + }, + browse_exceptions={ + MOCK_MEDIA_DIR_URI_BROWSE_ERROR: BrowseError( + "Mock directory failed to browse" + ) + }, + resolve_results={ + MOCK_MEDIA_IMAGE_URI_1: PlayMedia( + url="", + mime_type="image/png", + path=TEST_IMAGE, ), - ], + }, ) @pytest.fixture -def mock_media_source(browse_media_result: BrowseMediaSource): - """Mock browsing and resolving the configured media source.""" +def mock_media_source( + media_source_state: MediaSourceState, +) -> Iterator[MediaSourceMocks]: + """Patch media-source calls made by the collection-image integration.""" + + async def browse_side_effect( + _hass: HomeAssistant, + media_content_id: str, + *, + content_filter=None, + ) -> BrowseMediaSource: + if exception := media_source_state.browse_exceptions.get(media_content_id): + raise exception + + try: + return media_source_state.browse_results[media_content_id] + except KeyError as err: + raise ValueError( + f"Unexpected media content ID: {media_content_id}" + ) from err + + async def resolve_side_effect( + _hass: HomeAssistant, + media_content_id: str, + _entity_id: str, + ) -> PlayMedia: + if exception := media_source_state.resolve_exceptions.get(media_content_id): + raise exception + + try: + return media_source_state.resolve_results[media_content_id] + except KeyError as err: + raise ValueError( + f"Unexpected media content ID: {media_content_id}" + ) from err + with ( + patch( + "homeassistant.components.collection_image.config_flow.async_browse_media", + new=AsyncMock(side_effect=browse_side_effect), + ) as config_flow_browse, patch( "homeassistant.components.collection_image.image.async_browse_media", - new=AsyncMock(return_value=browse_media_result), - ) as mock_browse, + new=AsyncMock(side_effect=browse_side_effect), + ) as image_browse, patch( "homeassistant.components.collection_image.image.async_resolve_media", - new=AsyncMock( - return_value=PlayMedia( - url="", - mime_type="image/png", - path=TEST_IMAGE, - ) - ), - ) as mock_resolve, + new=AsyncMock(side_effect=resolve_side_effect), + ) as resolve, ): - yield mock_browse, mock_resolve + yield MediaSourceMocks( + config_flow_browse=config_flow_browse, + image_browse=image_browse, + resolve=resolve, + ) diff --git a/tests/components/collection_image/const.py b/tests/components/collection_image/const.py index 0c4bb7df0f1b9c..ce14fac9e44181 100644 --- a/tests/components/collection_image/const.py +++ b/tests/components/collection_image/const.py @@ -4,3 +4,9 @@ TEST_IMAGE = Path(__file__).parent / "test.png" DEFAULT_ENTITY_ID = "image.random_image" + +MOCK_MEDIA_DIR_URI_1 = "media-source://mymedia" +MOCK_MEDIA_DIR_URI_EMPTY = "media-source://mymedia_empty" +MOCK_MEDIA_DIR_URI_BROWSE_ERROR = "media-source://mymedia_error" + +MOCK_MEDIA_IMAGE_URI_1 = "media-source://mymedia/photo" diff --git a/tests/components/collection_image/helpers.py b/tests/components/collection_image/helpers.py new file mode 100644 index 00000000000000..e13741052584e9 --- /dev/null +++ b/tests/components/collection_image/helpers.py @@ -0,0 +1,54 @@ +"""Helper utilities for collection image tests.""" + +from homeassistant.components.collection_image.const import DOMAIN +from homeassistant.components.media_player import BrowseMedia, MediaClass +from homeassistant.components.media_source import BrowseMediaSource + +from tests.common import MockConfigEntry + + +def config_entry_from_uri(uri: str) -> MockConfigEntry: + """From a uri, construct a config entry.""" + return MockConfigEntry( + data={ + "media": { + "media_content_id": uri, + "media_content_type": "", + }, + }, + domain=DOMAIN, + title="Random Image", + ) + + +def image( + media_content_id: str, + *, + title: str = "a picture", +) -> BrowseMedia: + """Create a playable image browse result.""" + return BrowseMedia( + media_class=MediaClass.IMAGE, + media_content_id=media_content_id, + media_content_type="image/png", + title=title, + can_play=True, + can_expand=False, + ) + + +def directory( + title: str, + *children: BrowseMedia, +) -> BrowseMediaSource: + """Create an expandable browse result.""" + return BrowseMediaSource( + domain=None, + identifier=None, + media_class="", + media_content_type="", + title=title, + can_play=False, + can_expand=True, + children=list(children), + ) diff --git a/tests/components/collection_image/test_config_flow.py b/tests/components/collection_image/test_config_flow.py index 402b46475e44ed..9292854bf90d70 100644 --- a/tests/components/collection_image/test_config_flow.py +++ b/tests/components/collection_image/test_config_flow.py @@ -1,68 +1,43 @@ """Test the Collection Image config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries from homeassistant.components.collection_image.const import DOMAIN -from homeassistant.components.media_player import BrowseMedia, MediaClass -from homeassistant.components.media_source import BrowseMediaSource from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import ( + MOCK_MEDIA_DIR_URI_1, + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + MOCK_MEDIA_DIR_URI_EMPTY, +) -async def _assert_successful_configure( - hass: HomeAssistant, previous_step: config_entries.ConfigFlowResult -) -> None: - with ( - patch( - "homeassistant.components.collection_image.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.collection_image.config_flow.async_browse_media", - return_value=BrowseMediaSource( - domain=None, - identifier=None, - media_class="", - media_content_type="", - title="My pictures", - can_play=False, - can_expand=True, - children=[ - BrowseMedia( - media_class=MediaClass.IMAGE, - media_content_id="media-source://mymedia/photo", - media_content_type="image/png", - title="a picture", - can_play=True, - can_expand=False, - ), - ], - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - previous_step["flow_id"], - { - "media": { - "media_content_id": "media-source://mymedia", - "media_content_type": "", - }, - }, - ) - assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("title") == "My pictures collection" - assert result.get("data") == { +@pytest.fixture +def mock_setup_entry(): + """Mock collection_image setup successfully.""" + + with patch( + "homeassistant.components.collection_image.async_setup_entry", + new=AsyncMock(return_value=True), + ) as mock_setup: + yield mock_setup + + +def _data_from_uri(uri: str) -> dict: + return { "media": { - "media_content_id": "media-source://mymedia", + "media_content_id": uri, "media_content_type": "", - }, + } } - assert len(mock_setup_entry.mock_calls) == 1 -async def test_config_flow(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_media_source") +async def test_config_flow(hass: HomeAssistant, mock_setup_entry) -> None: """Test the config flow.""" result = await hass.config_entries.flow.async_init( @@ -71,60 +46,41 @@ async def test_config_flow(hass: HomeAssistant) -> None: assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - await _assert_successful_configure(hass, result) + data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) + expected_title = "My pictures collection" + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) -async def test_config_flow_with_error(hass: HomeAssistant) -> None: - """Test the config flow with an invalid directory.""" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == data + assert len(mock_setup_entry.mock_calls) == 1 - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {} - with ( - patch( - "homeassistant.components.collection_image.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.collection_image.config_flow.async_browse_media", - return_value=BrowseMediaSource( - domain=None, - identifier=None, - media_class="", - media_content_type="", - title="", - can_play=False, - can_expand=True, - children=[], - ), +@pytest.mark.parametrize( + ("uri", "error", "placeholders"), + [ + ( + MOCK_MEDIA_DIR_URI_EMPTY, + "selected_media_no_images", + {}, ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "media": { - "media_content_id": "media-source://mymedia_empty", - "media_content_type": "", - }, - }, - ) - await hass.async_block_till_done() - - assert result.get("type") is FlowResultType.FORM - assert result.get("title") is None - assert result.get("data") is None - assert result.get("errors") == {"media": "selected_media_no_images"} - assert len(mock_setup_entry.mock_calls) == 0 - - # Try again successfully to ensure we can recover from errors - await _assert_successful_configure(hass, result) - - -async def test_config_flow_with_exception(hass: HomeAssistant) -> None: - """Test the config flow with a browse failure.""" + ( + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + "failed_browse", + {"error": "Mock directory failed to browse"}, + ), + ], +) +@pytest.mark.usefixtures("mock_media_source") +async def test_config_flow_error( + hass: HomeAssistant, + mock_setup_entry, + uri: str, + error: str, + placeholders: dict, +) -> None: + """Test the config flow with an invalid media.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -132,30 +88,24 @@ async def test_config_flow_with_exception(hass: HomeAssistant) -> None: assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} - with ( - patch( - "homeassistant.components.collection_image.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "media": { - "media_content_id": "media-source://mymedia", - "media_content_type": "", - }, - }, - ) - await hass.async_block_till_done() + data = _data_from_uri(uri) + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + await hass.async_block_till_done() assert result.get("type") is FlowResultType.FORM assert result.get("title") is None assert result.get("data") is None - assert result.get("errors") == {"media": "failed_browse"} - assert result.get("description_placeholders") == { - "error": "Media Source not loaded" - } + assert result.get("errors") == {"media": error} + assert result.get("description_placeholders") == placeholders assert len(mock_setup_entry.mock_calls) == 0 - await _assert_successful_configure(hass, result) + # Try again successfully to ensure we can recover from errors + data = _data_from_uri(MOCK_MEDIA_DIR_URI_1) + expected_title = "My pictures collection" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], data) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == data + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/collection_image/test_image.py b/tests/components/collection_image/test_image.py index ffcb405f2ea709..0a554a2700be4a 100644 --- a/tests/components/collection_image/test_image.py +++ b/tests/components/collection_image/test_image.py @@ -7,9 +7,11 @@ from freezegun import freeze_time import pytest +from homeassistant.components.collection_image import DOMAIN from homeassistant.components.image import Image, async_get_image -from homeassistant.components.media_source import BrowseMediaSource, PlayMedia +from homeassistant.components.media_source import PlayMedia, Unresolvable from homeassistant.const import ( + ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STARTED, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -17,7 +19,15 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .const import DEFAULT_ENTITY_ID, TEST_IMAGE +from .conftest import MediaSourceMocks, MediaSourceState +from .const import ( + DEFAULT_ENTITY_ID, + MOCK_MEDIA_DIR_URI_BROWSE_ERROR, + MOCK_MEDIA_DIR_URI_EMPTY, + MOCK_MEDIA_IMAGE_URI_1, + TEST_IMAGE, +) +from .helpers import config_entry_from_uri from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -25,11 +35,25 @@ TEST_TIME = "2025-11-08T12:00:00+00:00" +async def _verify_path_image( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +): + client = await hass_client() + + resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + expected_data = await hass.async_add_executor_job(TEST_IMAGE.read_bytes) + body = await resp.read() + assert body == expected_data + + +@pytest.mark.usefixtures("mock_media_source") async def test_image( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - mock_media_source, ) -> None: """Test loading an image.""" with ( @@ -43,21 +67,14 @@ async def test_image( assert state and state.state == TEST_TIME - client = await hass_client() - - resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") - assert resp.status == HTTPStatus.OK - assert resp.content_type == "image/png" - expected_data = await hass.async_add_executor_job(TEST_IMAGE.read_bytes) - body = await resp.read() - assert body == expected_data + await _verify_path_image(hass, hass_client) async def test_image_during_startup( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - mock_media_source, + mock_media_source: MediaSourceMocks, ) -> None: """Test loading an image, ensuring that we don't browse until after startup is complete.""" with freeze_time(TEST_TIME): @@ -67,47 +84,39 @@ async def test_image_during_startup( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + mock_media_source.image_browse.assert_not_called() + mock_media_source.resolve.assert_not_called() + hass.set_state(CoreState.running) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + mock_media_source.image_browse.assert_awaited_once() + mock_media_source.resolve.assert_awaited_once() + state = hass.states.get(DEFAULT_ENTITY_ID) assert state and state.state == TEST_TIME - client = await hass_client() - - resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") - assert resp.status == HTTPStatus.OK - assert resp.content_type == "image/png" - expected_data = await hass.async_add_executor_job(TEST_IMAGE.read_bytes) - body = await resp.read() - assert body == expected_data + await _verify_path_image(hass, hass_client) +@pytest.mark.usefixtures("mock_media_source") async def test_image_url( hass: HomeAssistant, hass_client: ClientSessionGenerator, config_entry: MockConfigEntry, - browse_media_result: BrowseMediaSource, + media_source_state: MediaSourceState, ) -> None: """Test loading an image, when media resolves to a URL.""" + media_source_state.resolve_results[MOCK_MEDIA_IMAGE_URI_1] = PlayMedia( + url="http://example.com/test.png", + mime_type="image/png", + ) expected_data = await hass.async_add_executor_job(TEST_IMAGE.read_bytes) - with ( freeze_time(TEST_TIME), - patch( - "homeassistant.components.collection_image.image.async_browse_media", - return_value=browse_media_result, - ), - patch( - "homeassistant.components.collection_image.image.async_resolve_media", - return_value=PlayMedia( - url="http://example.com/test.png", - mime_type="image/png", - ), - ), ): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -136,29 +145,17 @@ async def test_image_url( assert body == expected_data +@pytest.mark.usefixtures("mock_media_source") async def test_no_images( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test when there are no images in the media folder.""" - with patch( - "homeassistant.components.collection_image.image.async_browse_media", - return_value=BrowseMediaSource( - domain=None, - identifier=None, - media_class="", - media_content_type="", - title="", - can_play=False, - can_expand=True, - children=[], - ), - ): - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_EMPTY) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() state = hass.states.get(DEFAULT_ENTITY_ID) @@ -167,7 +164,8 @@ async def test_no_images( await hass.async_block_till_done(wait_background_tasks=True) assert ( - "image.random_image: No valid images in media-source://mymedia" in caplog.text + f"image.random_image: No valid images in {MOCK_MEDIA_DIR_URI_EMPTY}" + in caplog.text ) client = await hass_client() @@ -175,14 +173,15 @@ async def test_no_images( assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR +@pytest.mark.usefixtures("mock_media_source") async def test_media_error( hass: HomeAssistant, hass_client: ClientSessionGenerator, - config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, ) -> None: """Test when media browse throws an error.""" + config_entry = config_entry_from_uri(MOCK_MEDIA_DIR_URI_BROWSE_ERROR) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -193,7 +192,7 @@ async def test_media_error( await hass.async_block_till_done(wait_background_tasks=True) - assert "image.random_image: Media Source not loaded" in caplog.text + assert "image.random_image: Mock directory failed to browse" in caplog.text client = await hass_client() resp = await client.get(f"/api/image_proxy/{DEFAULT_ENTITY_ID}") @@ -203,20 +202,22 @@ async def test_media_error( async def test_unresolvable( hass: HomeAssistant, config_entry: MockConfigEntry, - browse_media_result: BrowseMediaSource, + media_source_state: MediaSourceState, + mock_media_source: MediaSourceMocks, caplog: pytest.LogCaptureFixture, + hass_client: ClientSessionGenerator, ) -> None: """Test when resolving an image fails.""" + media_source_state.resolve_exceptions[MOCK_MEDIA_IMAGE_URI_1] = Unresolvable( + "Mock image failed to resolve" + ) - with ( - patch( - "homeassistant.components.collection_image.image.async_browse_media", - return_value=browse_media_result, - ), - ): - config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_media_source.image_browse.call_count == 1 + assert mock_media_source.resolve.call_count == 1 state = hass.states.get(DEFAULT_ENTITY_ID) @@ -224,32 +225,48 @@ async def test_unresolvable( await hass.async_block_till_done(wait_background_tasks=True) - assert "image.random_image: Media Source not loaded" in caplog.text + assert "image.random_image: Mock image failed to resolve" in caplog.text + + # Test we can recover by calling shuffle again when the image is resolvable + del media_source_state.resolve_exceptions[MOCK_MEDIA_IMAGE_URI_1] + + with ( + freeze_time(TEST_TIME), + ): + await hass.services.async_call( + DOMAIN, + "shuffle", + {ATTR_ENTITY_ID: DEFAULT_ENTITY_ID}, + blocking=True, + ) + + assert mock_media_source.image_browse.call_count == 2 + assert mock_media_source.resolve.call_count == 2 + + state = hass.states.get(DEFAULT_ENTITY_ID) + + assert state and state.state == TEST_TIME + + await _verify_path_image(hass, hass_client) +@pytest.mark.usefixtures("mock_media_source") async def test_image_file_read_error( hass: HomeAssistant, config_entry: MockConfigEntry, - browse_media_result: BrowseMediaSource, + media_source_state: MediaSourceState, hass_client: ClientSessionGenerator, ) -> None: """Test that a file read error is surfaced when serving the image.""" missing_path = Path(__file__).parent / "does_not_exist.png" + media_source_state.resolve_results[MOCK_MEDIA_IMAGE_URI_1] = PlayMedia( + url="", + mime_type="image/png", + path=missing_path, + ) with ( freeze_time(TEST_TIME), - patch( - "homeassistant.components.collection_image.image.async_browse_media", - return_value=browse_media_result, - ), - patch( - "homeassistant.components.collection_image.image.async_resolve_media", - return_value=PlayMedia( - url="", - mime_type="image/png", - path=missing_path, - ), - ), ): config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/common.py b/tests/components/common.py index fdfe3094520891..b359e75de34f50 100644 --- a/tests/components/common.py +++ b/tests/components/common.py @@ -3,18 +3,17 @@ from collections.abc import Iterable import copy from enum import StrEnum +import inspect import itertools import logging from pathlib import Path +import re from typing import Any, TypedDict import pytest import voluptuous as vol from homeassistant.const import ( - ATTR_AREA_ID, - ATTR_DEVICE_ID, - ATTR_FLOOR_ID, ATTR_LABEL_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_CONDITION, @@ -29,17 +28,22 @@ from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, floor_registry as fr, label_registry as lr, ) from homeassistant.helpers.condition import ( + Condition, ConditionCheckerTypeOptional, + EntityConditionBase, async_from_config as async_condition_from_config, async_validate_condition_config, ) from homeassistant.helpers.trigger import ( + EntityTriggerBase, + Trigger, async_initialize_triggers, async_validate_trigger_config, ) @@ -185,7 +189,31 @@ async def target_entities( def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]: """Parametrize target entities for different target types. - Meant to be used with target_entities. + Meant to be used with target_entities. Each row is a + ``(target_config, entity_id, entities_in_target)`` triple. + + Only two representative rows are kept: + + - ``entity`` — a direct ``entity_id`` reference to two standalone entities. + This preserves the direct-reference resolution path (state-machine-only, + non-registry entities) and a guaranteed multi-entity target, so the + all/first/count behavior assertions stay non-vacuous. + - ``label-entity`` — a ``label_id`` reference that resolves to three + entities: the labeled entity directly and, via label->device expansion, + the device-attached entities. This preserves indirect resolution + end-to-end plus everything that only fires when excluded entities sit + inside the resolved target scope: in-scope cross-domain exclusion (E2), + in-scope device_class/feature-filter negatives (E3), and battery's + ``primary_entities_only=False`` flag, which only has an observable effect + when a categorized entity is reached through device expansion (E1). + + The dropped rows (area, floor, device_id, and the label/area/floor rows + that drive the device-attached entity) only varied *how* a target config + resolves to an entity set. That resolution is domain-independent machinery + covered centrally by ``tests/helpers/test_target.py`` (area->entity, + floor->area, area/label/floor->device, direct device incl. child devices, + and the ``primary_entities_only`` category asymmetry), so re-exercising it + per domain was pure duplication. """ return [ ( @@ -199,15 +227,269 @@ def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]: 2, ), ({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 3), - ({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 3), - ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 3), - ({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 3), - ({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 3), - ({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 3), - ({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 2), ] +class TargetSupport(StrEnum): + """Declared level of user-target support for a registered trigger/condition.""" + + # Supports a user target via the shared entity base-class machinery: fully + # certified (subclasses the entity base, inherits the target machinery + # unmodified, carries the `target: cv.TARGET_FIELDS` slot, and passes + # init/entity_filter hygiene). + STANDARD = "standard" + # Does not support a user target (synthesized or absent); asserted to expose + # no `target` schema slot. + NONE = "none" + # Supports a user target but resolves it with its own machinery; only a + # `target: cv.TARGET_FIELDS` slot is asserted, and the machinery/entity-base + # checks are skipped (its own dedicated tests cover resolution correctness). + CUSTOM = "custom" + + +# Target-resolution machinery a target-supporting trigger/condition class must +# inherit unchanged from its entity base class. ``entity_filter`` is intentionally +# absent: it runs inside the resolution choke point on the already-resolved entity +# set, so an override that narrows the base result is allowed and is checked +# separately by _entity_filter_hygiene_violation. +_TRIGGER_TARGET_MACHINERY = frozenset( + { + "async_validate_complete_config", + "async_validate_config", + "async_attach_action", + "async_attach_runner", + "count_matches", + "_cancel_invalidated_timers", + "_combined_state_still_valid", + } +) +_CONDITION_TARGET_MACHINERY = frozenset( + { + "async_validate_complete_config", + "async_validate_config", + "async_check", + "async_unload", + "_async_unload", + "_async_setup", + "_async_check", + "_check_any_match_state", + "_check_all_match_state", + "_async_on_entities_update", + "_async_prime_valid_since", + "_async_refine_anchors_from_history", + "_valid_since_from_history", + "_update_valid_since", + } +) +_TARGET_HELPER_MODULES = frozenset( + {"homeassistant.helpers.trigger", "homeassistant.helpers.condition"} +) + + +def _foreign_names(cls: type) -> set[str]: + """Return names defined by MRO classes outside the trigger/condition helpers.""" + names: set[str] = set() + for klass in cls.__mro__: + if klass.__module__ in _TARGET_HELPER_MODULES: + continue + names.update(vars(klass)) + return names + + +def _target_slot_validator(cls: type) -> object | None: + """Return the ``target`` schema validator for a class, or None if it has none. + + A class exposes a user-configurable target iff its ``_schema`` carries a + ``target`` marker. Bespoke or synthesized-target classes (e.g. zone.occupancy_* + or the legacy ``_`` platforms) have no such marker. + """ + mapping = getattr(getattr(cls, "_schema", None), "schema", None) + if not isinstance(mapping, dict): + return None + for marker, validator in mapping.items(): + if str(marker) == "target": + return validator + return None + + +def _init_hygiene_violation(cls: type, key: str, config_cls_name: str) -> str | None: + """Return an error if an __init__ override rewrites the config or target.""" + for klass in cls.__mro__: + if klass.__module__ in _TARGET_HELPER_MODULES: + return None + if "__init__" not in vars(klass): + continue + src = inspect.getsource(klass.__init__) + if "super().__init__(hass, config)" not in src: + return f"{key}: __init__ must delegate the unmodified config to super()" + if re.search(r"self\._target\b\s*=", src): + return f"{key}: __init__ must not assign self._target" + if f"{config_cls_name}(" in src or "replace(config" in src: + return f"{key}: __init__ must not rebuild the config object" + # ConditionConfig is @dataclass(slots=True) but NOT frozen (unlike + # TriggerConfig), so a mutation would rewrite the user target at runtime + # while passing the checks above. The (?!=) excludes the `==` comparison + # and the optional subscript excludes reads. Freezing ConditionConfig + # upstream would be a stronger, product-side fix. + if re.search(r"config\.target\b\s*(?:\[[^\]]*\])?\s*=(?!=)", src): + return f"{key}: __init__ must not assign to config.target" + continue + return None + + +def _entity_filter_hygiene_violation(cls: type, key: str) -> str | None: + """Return an error if an entity_filter override does not narrow the base.""" + for klass in cls.__mro__: + if klass.__module__ in _TARGET_HELPER_MODULES: + return None + if "entity_filter" not in vars(klass): + continue + src = inspect.getsource(klass.entity_filter) + if "super().entity_filter(" not in src: + return f"{key}: entity_filter override must narrow the base result" + continue + return None + + +def _target_supporting_violations( + cls: type, + key: str, + *, + entity_base: type, + machinery: frozenset[str], + config_cls: str, +) -> list[str]: + """Return violations for a class declared to support a user target.""" + if not issubclass(cls, entity_base): + return [ + f"{key} ({cls.__name__}): declared target-supporting but does not " + f"subclass {entity_base.__name__}" + ] + violations: list[str] = [] + overridden = _foreign_names(cls) & machinery + if overridden: + violations.append( + f"{key} overrides target machinery {sorted(overridden)}; it no longer " + "inherits the shared target resolution" + ) + if _target_slot_validator(cls) is not cv.TARGET_FIELDS: + violations.append( + f"{key}: schema does not carry the standard `target: cv.TARGET_FIELDS` slot" + ) + violations.extend( + violation + for violation in ( + _init_hygiene_violation(cls, key, config_cls), + _entity_filter_hygiene_violation(cls, key), + ) + if violation is not None + ) + return violations + + +def _assert_target_support( + registry: dict[str, type], + declaration: dict[str, TargetSupport], + *, + entity_base: type, + machinery: frozenset[str], + config_cls: str, + kind: str, +) -> None: + """Certify one registry against its ``key -> TargetSupport`` declaration.""" + missing = set(registry) - set(declaration) + extra = set(declaration) - set(registry) + assert not missing, ( + f"{kind}s registered but not declared: {sorted(missing)} -- add them to the " + "target-support declaration" + ) + assert not extra, ( + f"{kind}s declared but not registered: {sorted(extra)} -- remove them from " + "the target-support declaration" + ) + + violations: list[str] = [] + for key in sorted(registry): + cls = registry[key] + support = declaration[key] + if support is TargetSupport.STANDARD: + violations.extend( + _target_supporting_violations( + cls, + key, + entity_base=entity_base, + machinery=machinery, + config_cls=config_cls, + ) + ) + elif support is TargetSupport.NONE: + if _target_slot_validator(cls) is not None: + violations.append( + f"{key}: declared TargetSupport.NONE, but its schema exposes a " + "`target` slot" + ) + elif support is TargetSupport.CUSTOM: + # Custom-machinery target: only require that a user target slot + # exists; the class's own tests cover resolution correctness. The + # standard machinery/entity-base checks are intentionally skipped, + # and this state must be opted into explicitly -- a class declared + # STANDARD that forgot to subclass the entity base still fails above. + if _target_slot_validator(cls) is not cv.TARGET_FIELDS: + violations.append( + f"{key}: declared TargetSupport.CUSTOM but its schema does not " + "carry a `target: cv.TARGET_FIELDS` slot" + ) + else: + violations.append( + f"{key}: invalid target-support declaration value {support!r}; use a " + "TargetSupport member" + ) + assert not violations, f"{kind} target-support violations:\n" + "\n".join( + violations + ) + + +def assert_triggers_target_support( + registry: dict[str, type[Trigger]], declaration: dict[str, TargetSupport] +) -> None: + """Certify a domain's trigger registry against its target-support declaration. + + ``declaration`` maps every registered trigger key to a ``TargetSupport`` member: + ``STANDARD`` (inherits the shared target-resolution machinery unmodified, so the + collapsed two-row target axis still certifies it), ``NONE`` (exposes no + ``target`` schema slot -- bespoke or synthesized target, e.g. zone.occupancy_*), + or ``CUSTOM`` (exposes a ``target`` slot but resolves it with its own machinery, + e.g. timer.remaining_time_reached). Battery's ``primary_entities_only=False`` is + intentionally not pinned here -- its behavior tests with DIAGNOSTIC fixtures + already fail loudly on a silent flip. + """ + _assert_target_support( + registry, + declaration, + entity_base=EntityTriggerBase, + machinery=_TRIGGER_TARGET_MACHINERY, + config_cls="TriggerConfig", + kind="trigger", + ) + + +def assert_conditions_target_support( + registry: dict[str, type[Condition]], declaration: dict[str, TargetSupport] +) -> None: + """Certify a domain's condition registry against its target-support declaration. + + See assert_triggers_target_support; the same contract on the condition base. + """ + _assert_target_support( + registry, + declaration, + entity_base=EntityConditionBase, + machinery=_CONDITION_TARGET_MACHINERY, + config_cls="ConditionConfig", + kind="condition", + ) + + class StateDescription(TypedDict): """Test state with attributes.""" diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py index ed19d02c2be544..3e543f28baed82 100644 --- a/tests/components/counter/test_condition.py +++ b/tests/components/counter/test_condition.py @@ -4,13 +4,16 @@ import pytest +from homeassistant.components.counter.condition import CONDITIONS from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -27,6 +30,11 @@ async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]: _PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +58,11 @@ async def test_counter_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("counter"), diff --git a/tests/components/counter/test_trigger.py b/tests/components/counter/test_trigger.py index f1bac858f5798e..c0273eb459211b 100644 --- a/tests/components/counter/test_trigger.py +++ b/tests/components/counter/test_trigger.py @@ -10,17 +10,20 @@ CONF_MINIMUM, DOMAIN, ) +from homeassistant.components.counter.trigger import TRIGGERS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, set_or_remove_state, @@ -52,6 +55,15 @@ async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "decremented": TargetSupport.STANDARD, + "incremented": TargetSupport.STANDARD, + "maximum_reached": TargetSupport.STANDARD, + "minimum_reached": TargetSupport.STANDARD, + "reset": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -79,6 +91,11 @@ async def test_counter_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/cover/test_condition.py b/tests/components/cover/test_condition.py index 4a23251e552406..96d798b641d011 100644 --- a/tests/components/cover/test_condition.py +++ b/tests/components/cover/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.components.cover.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -35,6 +38,20 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "awning_is_closed": TargetSupport.STANDARD, + "awning_is_open": TargetSupport.STANDARD, + "blind_is_closed": TargetSupport.STANDARD, + "blind_is_open": TargetSupport.STANDARD, + "curtain_is_closed": TargetSupport.STANDARD, + "curtain_is_open": TargetSupport.STANDARD, + "shade_is_closed": TargetSupport.STANDARD, + "shade_is_open": TargetSupport.STANDARD, + "shutter_is_closed": TargetSupport.STANDARD, + "shutter_is_open": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -67,6 +84,11 @@ async def test_cover_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("cover"), diff --git a/tests/components/cover/test_trigger.py b/tests/components/cover/test_trigger.py index 954edbef257971..cef8d61f3f1db9 100644 --- a/tests/components/cover/test_trigger.py +++ b/tests/components/cover/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState +from homeassistant.components.cover.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -34,6 +37,20 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "awning_opened": TargetSupport.STANDARD, + "awning_closed": TargetSupport.STANDARD, + "blind_opened": TargetSupport.STANDARD, + "blind_closed": TargetSupport.STANDARD, + "curtain_opened": TargetSupport.STANDARD, + "curtain_closed": TargetSupport.STANDARD, + "shade_opened": TargetSupport.STANDARD, + "shade_closed": TargetSupport.STANDARD, + "shutter_opened": TargetSupport.STANDARD, + "shutter_closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -66,6 +83,11 @@ async def test_cover_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("cover"), diff --git a/tests/components/door/test_condition.py b/tests/components/door/test_condition.py index 708907a13aabd1..f16b87e9072e2e 100644 --- a/tests/components/door/test_condition.py +++ b/tests/components/door/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.components.door.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_closed": TargetSupport.STANDARD, + "is_open": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_door_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + # --- binary_sensor tests --- diff --git a/tests/components/door/test_trigger.py b/tests/components/door/test_trigger.py index 67d5de0345a85b..0374e06f943ceb 100644 --- a/tests/components/door/test_trigger.py +++ b/tests/components/door/test_trigger.py @@ -6,15 +6,18 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState +from homeassistant.components.door.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "opened": TargetSupport.STANDARD, + "closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_door_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/doorbell/test_trigger.py b/tests/components/doorbell/test_trigger.py index 6679201f3a530c..6bdbfc1eb06e38 100644 --- a/tests/components/doorbell/test_trigger.py +++ b/tests/components/doorbell/test_trigger.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.doorbell.trigger import TRIGGERS from homeassistant.components.event import ATTR_EVENT_TYPE from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, + TargetSupport, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -24,6 +27,11 @@ async def target_events(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "event") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "rang": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -47,6 +55,11 @@ async def test_doorbell_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("event"), diff --git a/tests/components/event/test_trigger.py b/tests/components/event/test_trigger.py index 01c79408115f0b..da3c466061d5d5 100644 --- a/tests/components/event/test_trigger.py +++ b/tests/components/event/test_trigger.py @@ -7,15 +7,18 @@ from homeassistant.components.event import DOMAIN, EventEntity from homeassistant.components.event.const import ATTR_EVENT_TYPE +from homeassistant.components.event.trigger import TRIGGERS from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockEntity, setup_test_component_platform from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -48,6 +51,11 @@ async def target_events(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "event") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "received": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -71,6 +79,11 @@ async def test_event_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("event"), diff --git a/tests/components/fan/test_condition.py b/tests/components/fan/test_condition.py index db8c21e09e87ac..d3117f21085009 100644 --- a/tests/components/fan/test_condition.py +++ b/tests/components/fan/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.fan.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,12 @@ async def target_fans(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "fan", domain_excluded="switch") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +58,11 @@ async def test_fan_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("fan"), diff --git a/tests/components/fan/test_trigger.py b/tests/components/fan/test_trigger.py index 6b7b03d79bfcbf..025d982ba9987d 100644 --- a/tests/components/fan/test_trigger.py +++ b/tests/components/fan/test_trigger.py @@ -4,15 +4,18 @@ import pytest +from homeassistant.components.fan.trigger import TRIGGERS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -25,6 +28,12 @@ async def target_fans(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "fan") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +58,11 @@ async def test_fan_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("fan"), diff --git a/tests/components/flexit/snapshots/test_binary_sensor.ambr b/tests/components/flexit/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..be212fa19e5969 --- /dev/null +++ b/tests/components/flexit/snapshots/test_binary_sensor.ambr @@ -0,0 +1,102 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.flexit_electric_heater_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.flexit_electric_heater_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electric heater enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Electric heater enabled', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electric_heater_enabled', + 'unique_id': 'flexit_001-electric_heater_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.flexit_electric_heater_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Flexit Electric heater enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.flexit_electric_heater_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[binary_sensor.flexit_filter_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.flexit_filter_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Filter alarm', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Filter alarm', + 'platform': 'flexit', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_alarm', + 'unique_id': 'flexit_001-filter_alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.flexit_filter_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'problem', + : 'Flexit Filter alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.flexit_filter_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/flexit/test_binary_sensor.py b/tests/components/flexit/test_binary_sensor.py new file mode 100644 index 00000000000000..e445a97bf48ea6 --- /dev/null +++ b/tests/components/flexit/test_binary_sensor.py @@ -0,0 +1,43 @@ +"""Test the Flexit binary sensor platform.""" + +from unittest.mock import patch + +from modbus_connection.mock import MockModbusUnit +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_binary_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_modbus_unit: MockModbusUnit, +) -> None: + """Test Flexit binary sensor states.""" + mock_modbus_unit.holding.update({8: 215, 17: 2}) + mock_modbus_unit.input.update( + { + 8: 120, + 9: 200, + 11: 50, + 13: 0, + 14: 0, + 15: 0, + 27: 1, + 28: 1, + 48: 0, + } + ) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.flexit._PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/flexit/test_climate.py b/tests/components/flexit/test_climate.py index 2118e1d794f57c..24f20c8762ab97 100644 --- a/tests/components/flexit/test_climate.py +++ b/tests/components/flexit/test_climate.py @@ -15,7 +15,7 @@ ) from homeassistant.components.flexit.climate import async_setup_platform from homeassistant.components.flexit.const import DOMAIN -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( @@ -90,7 +90,10 @@ async def test_climate_entity( mock_config_entry: MockConfigEntry, ) -> None: """Test climate entity setup and state.""" - await _setup_integration(hass, mock_config_entry) + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.flexit._PLATFORMS", [Platform.CLIMATE]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/flexit/test_config_flow.py b/tests/components/flexit/test_config_flow.py index 9382db2a47a2b3..73b85fbef778d9 100644 --- a/tests/components/flexit/test_config_flow.py +++ b/tests/components/flexit/test_config_flow.py @@ -6,7 +6,12 @@ from modbus_connection.mock import MockModbusUnit import pytest -from homeassistant.components.flexit.const import CONF_UNIT, DOMAIN, TYPE_TCP +from homeassistant.components.flexit.const import ( + CONF_BAUDRATE, + CONF_UNIT, + DOMAIN, + TYPE_TCP, +) from homeassistant.config_entries import ( SOURCE_RECONFIGURE, SOURCE_USER, @@ -126,6 +131,12 @@ async def test_full_flow_serial( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "serial" assert result["errors"] == {} + assert ( + result["data_schema"]({CONF_DEVICE: "/dev/ttyUSB0", CONF_UNIT: 1})[ + CONF_BAUDRATE + ] + == 9600 + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index 04651d1234d677..adbd60c1f000b1 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -741,7 +741,7 @@ 'mppt_1_current_dc': dict({ 'value': 8.2, }), - 'mppt_1_energy_dc': dict({ + 'mppt_1_energy': dict({ 'value': 1000000, }), 'mppt_1_power_dc': dict({ diff --git a/tests/components/fronius/test_modbus.py b/tests/components/fronius/test_modbus.py index 5305a540d8a289..9806d210128190 100644 --- a/tests/components/fronius/test_modbus.py +++ b/tests/components/fronius/test_modbus.py @@ -74,7 +74,7 @@ async def test_gen24_storage_mppt( assert_state(hass, "sensor.gen24_storage_mppt_1_dc_current", 8.2) assert_state(hass, "sensor.gen24_storage_mppt_1_dc_voltage", 402.1) assert_state(hass, "sensor.gen24_storage_mppt_1_dc_power", 3300) - assert_state(hass, "sensor.gen24_storage_mppt_1_dc_energy", 1000000) + assert_state(hass, "sensor.gen24_storage_mppt_1_energy", 1000000) assert_state(hass, "sensor.gen24_storage_mppt_2_dc_power", 1650) assert_state(hass, "sensor.gen24_storage_mppt_3_dc_power", 0) assert_state(hass, "sensor.gen24_storage_mppt_4_dc_power", 480) @@ -301,7 +301,7 @@ async def test_not_implemented_values( assert_state(hass, "sensor.inverter_name_mppt_1_dc_power", 3300) assert hass.states.get("sensor.inverter_name_mppt_2_dc_power") is None - assert hass.states.get("sensor.inverter_name_mppt_2_dc_energy") is None + assert hass.states.get("sensor.inverter_name_mppt_2_energy") is None # PV total unknown when a PV module doesn't report energy assert hass.states.get("sensor.inverter_name_pv_energy_total") is None @@ -312,7 +312,7 @@ async def test_not_implemented_values( freezer.tick(FroniusModbusInverterUpdateCoordinator.default_interval) async_fire_time_changed(hass) await hass.async_block_till_done() - assert_state(hass, "sensor.inverter_name_mppt_1_dc_energy", "unknown") + assert_state(hass, "sensor.inverter_name_mppt_1_energy", "unknown") @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/garage_door/test_condition.py b/tests/components/garage_door/test_condition.py index cdf548ef328aac..5a391594aa05fc 100644 --- a/tests/components/garage_door/test_condition.py +++ b/tests/components/garage_door/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.components.garage_door.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_closed": TargetSupport.STANDARD, + "is_open": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_garage_door_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + # --- binary_sensor tests --- diff --git a/tests/components/garage_door/test_trigger.py b/tests/components/garage_door/test_trigger.py index 1b8f40f42a9505..b81169f72bb66a 100644 --- a/tests/components/garage_door/test_trigger.py +++ b/tests/components/garage_door/test_trigger.py @@ -6,15 +6,18 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState +from homeassistant.components.garage_door.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "opened": TargetSupport.STANDARD, + "closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_garage_door_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/gate/test_condition.py b/tests/components/gate/test_condition.py index 0487a005209e84..12b5559a497c4b 100644 --- a/tests/components/gate/test_condition.py +++ b/tests/components/gate/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.components.gate.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -27,6 +30,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_closed": TargetSupport.STANDARD, + "is_open": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -51,6 +60,11 @@ async def test_gate_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("cover"), diff --git a/tests/components/gate/test_trigger.py b/tests/components/gate/test_trigger.py index 467d1f60276e02..645b0092a76ff6 100644 --- a/tests/components/gate/test_trigger.py +++ b/tests/components/gate/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState +from homeassistant.components.gate.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "opened": TargetSupport.STANDARD, + "closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_gate_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("cover"), diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index e6d40f0de3c87f..be53dd772f183f 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -1556,6 +1556,7 @@ async def test_working_location_get_events( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, { "start": "2026-08-25", @@ -1563,6 +1564,7 @@ async def test_working_location_get_events( "summary": "Office", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, { "start": "2026-08-31", @@ -1570,6 +1572,7 @@ async def test_working_location_get_events( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", }, ] } @@ -1652,6 +1655,7 @@ async def test_working_location_ignore_availability_false( "summary": "Home", "description": "test event", "location": "Test Cases", + "status": "confirmed", } ] } @@ -1778,3 +1782,37 @@ async def test_calendar_background_color( entity = entity_registry.async_get("calendar.test_calendar") assert entity is not None assert entity.options.get("calendar", {}).get("color") == expected_color + + +@pytest.mark.freeze_time("2022-03-27 12:05:00+00:00") +@pytest.mark.parametrize( + ("event_status", "expected_status"), + [ + pytest.param({"status": "tentative"}, "tentative", id="tentative"), + pytest.param({"status": "confirmed"}, "confirmed", id="confirmed"), + # The Google API documents confirmed as the default for an omitted + # status and gcal_sync applies it, so it is never reported as unset. + pytest.param({}, "confirmed", id="defaults_to_confirmed"), + ], + # Cancelled is not covered: in the Google API it means deleted rather than + # called off, and gcal_sync drops those events when building the timeline, + # so they never reach the integration. +) +async def test_http_api_event_status( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_events_list_items: ApiResult, + component_setup: ComponentSetup, + event_status: dict[str, str], + expected_status: str, +) -> None: + """Test that the event status is returned by the API.""" + mock_events_list_items([{**TEST_EVENT, **upcoming(), **event_status}]) + assert await component_setup() + + client = await hass_client() + response = await client.get(upcoming_event_url()) + assert response.status == HTTPStatus.OK + events = await response.json() + assert len(events) == 1 + assert events[0]["status"] == expected_status diff --git a/tests/components/habitica/snapshots/test_calendar.ambr b/tests/components/habitica/snapshots/test_calendar.ambr index 68d93d7fb7f7a1..0a6c9bf7f3dbd3 100644 --- a/tests/components/habitica/snapshots/test_calendar.ambr +++ b/tests/components/habitica/snapshots/test_calendar.ambr @@ -28,6 +28,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -42,6 +43,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -56,6 +58,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -70,6 +73,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -84,6 +88,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -98,6 +103,7 @@ 'start': dict({ 'date': '2024-09-22', }), + 'status': None, 'summary': 'Arbeite an einem kreativen Projekt', 'uid': '6e53f1f5-a315-4edd-984d-8d762e4a08ef', }), @@ -112,6 +118,7 @@ 'start': dict({ 'date': '2024-09-23', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -126,6 +133,7 @@ 'start': dict({ 'date': '2024-09-23', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -140,6 +148,7 @@ 'start': dict({ 'date': '2024-09-24', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -154,6 +163,7 @@ 'start': dict({ 'date': '2024-09-24', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -168,6 +178,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -182,6 +193,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -196,6 +208,7 @@ 'start': dict({ 'date': '2024-09-25', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -210,6 +223,7 @@ 'start': dict({ 'date': '2024-09-26', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -224,6 +238,7 @@ 'start': dict({ 'date': '2024-09-26', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -238,6 +253,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -252,6 +268,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -266,6 +283,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -280,6 +298,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -294,6 +313,7 @@ 'start': dict({ 'date': '2024-09-28', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -308,6 +328,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -322,6 +343,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -336,6 +358,7 @@ 'start': dict({ 'date': '2024-09-29', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -350,6 +373,7 @@ 'start': dict({ 'date': '2024-09-30', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -364,6 +388,7 @@ 'start': dict({ 'date': '2024-09-30', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -378,6 +403,7 @@ 'start': dict({ 'date': '2024-10-01', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -392,6 +418,7 @@ 'start': dict({ 'date': '2024-10-01', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -406,6 +433,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -420,6 +448,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -434,6 +463,7 @@ 'start': dict({ 'date': '2024-10-02', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -448,6 +478,7 @@ 'start': dict({ 'date': '2024-10-03', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -462,6 +493,7 @@ 'start': dict({ 'date': '2024-10-03', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -476,6 +508,7 @@ 'start': dict({ 'date': '2024-10-04', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -490,6 +523,7 @@ 'start': dict({ 'date': '2024-10-04', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -504,6 +538,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -518,6 +553,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -532,6 +568,7 @@ 'start': dict({ 'date': '2024-10-05', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -546,6 +583,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -560,6 +598,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -574,6 +613,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Fitnessstudio besuchen', 'uid': '2c6d136c-a1c3-4bef-b7c4-fa980784b1e1', }), @@ -588,6 +628,7 @@ 'start': dict({ 'date': '2024-10-06', }), + 'status': None, 'summary': 'Monatliche Finanzübersicht erstellen', 'uid': '369afeed-61e3-4bf7-9747-66e05807134c', }), @@ -602,6 +643,7 @@ 'start': dict({ 'date': '2024-10-07', }), + 'status': None, 'summary': 'Zahnseide benutzen', 'uid': '564b9ac9-c53d-4638-9e7f-1cd96fe19baa', }), @@ -616,6 +658,7 @@ 'start': dict({ 'date': '2024-10-07', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f', }), @@ -634,6 +677,7 @@ 'start': dict({ 'dateTime': '2024-09-21T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -648,6 +692,7 @@ 'start': dict({ 'dateTime': '2024-09-22T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -662,6 +707,7 @@ 'start': dict({ 'dateTime': '2024-09-23T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -676,6 +722,7 @@ 'start': dict({ 'dateTime': '2024-09-24T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -690,6 +737,7 @@ 'start': dict({ 'dateTime': '2024-09-25T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -704,6 +752,7 @@ 'start': dict({ 'dateTime': '2024-09-26T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -718,6 +767,7 @@ 'start': dict({ 'dateTime': '2024-09-27T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -732,6 +782,7 @@ 'start': dict({ 'dateTime': '2024-09-28T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -746,6 +797,7 @@ 'start': dict({ 'dateTime': '2024-09-29T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -760,6 +812,7 @@ 'start': dict({ 'dateTime': '2024-09-30T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -774,6 +827,7 @@ 'start': dict({ 'dateTime': '2024-10-01T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -788,6 +842,7 @@ 'start': dict({ 'dateTime': '2024-10-02T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -802,6 +857,7 @@ 'start': dict({ 'dateTime': '2024-10-03T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -816,6 +872,7 @@ 'start': dict({ 'dateTime': '2024-10-04T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -830,6 +887,7 @@ 'start': dict({ 'dateTime': '2024-10-05T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -844,6 +902,7 @@ 'start': dict({ 'dateTime': '2024-10-06T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -858,6 +917,7 @@ 'start': dict({ 'dateTime': '2024-10-07T20:00:00+02:00', }), + 'status': None, 'summary': '5 Minuten ruhig durchatmen', 'uid': 'f2c85972-1a19-4426-bc6d-ce3337b9d99f_1491d640-6b21-4d0c-8940-0b7aa61c8836', }), @@ -876,6 +936,7 @@ 'start': dict({ 'dateTime': '2024-09-22T02:00:00+02:00', }), + 'status': None, 'summary': 'Rechnungen bezahlen', 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490_91c09432-10ac-4a49-bd20-823081ec29ed', }), @@ -894,6 +955,7 @@ 'start': dict({ 'date': '2024-08-31', }), + 'status': None, 'summary': 'Rechnungen bezahlen', 'uid': '2f6fcabc-f670-4ec3-ba65-817e8deea490', }), @@ -908,6 +970,7 @@ 'start': dict({ 'date': '2024-09-21', }), + 'status': None, 'summary': 'Wochenendausflug planen', 'uid': '86ea2475-d1b5-4020-bdcc-c188c7996afa', }), @@ -922,6 +985,7 @@ 'start': dict({ 'date': '2024-09-27', }), + 'status': None, 'summary': 'Buch zu Ende lesen', 'uid': '88de7cd9-af2b-49ce-9afd-bf941d87336b', }), diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index 32c60ff04cd8f8..560ea0525174b5 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -6,6 +6,7 @@ import pytest import voluptuous as vol +from homeassistant.components.humidifier.condition import CONDITIONS from homeassistant.components.humidifier.const import ( ATTR_ACTION, ATTR_HUMIDITY, @@ -27,9 +28,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_attribute_condition_above_below_all, @@ -48,6 +51,16 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: _HUMIDITY_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, + "is_drying": TargetSupport.STANDARD, + "is_humidifying": TargetSupport.STANDARD, + "is_mode": TargetSupport.STANDARD, + "is_target_humidity": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -76,6 +89,11 @@ async def test_humidifier_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("humidifier"), diff --git a/tests/components/humidifier/test_trigger.py b/tests/components/humidifier/test_trigger.py index 268fa637eafce9..9a21651c3b1b13 100644 --- a/tests/components/humidifier/test_trigger.py +++ b/tests/components/humidifier/test_trigger.py @@ -11,6 +11,7 @@ HumidifierAction, HumidifierEntityFeature, ) +from homeassistant.components.humidifier.trigger import TRIGGERS from homeassistant.const import ( ATTR_MODE, ATTR_SUPPORTED_FEATURES, @@ -25,11 +26,13 @@ from homeassistant.helpers.trigger import async_validate_trigger_config from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -42,6 +45,15 @@ async def target_humidifiers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "humidifier") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "mode_changed": TargetSupport.STANDARD, + "started_drying": TargetSupport.STANDARD, + "started_humidifying": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -69,6 +81,11 @@ async def test_humidifier_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("humidifier"), diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index fd33232ec272ba..fc1b3b49ab1e8c 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -11,15 +11,18 @@ from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ) +from homeassistant.components.humidity.condition import CONDITIONS from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, parametrize_numerical_condition_above_below_all, @@ -58,6 +61,11 @@ async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: _PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -81,6 +89,11 @@ async def test_humidity_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), diff --git a/tests/components/humidity/test_trigger.py b/tests/components/humidity/test_trigger.py index 7bee85ba78776c..6d7381f404243e 100644 --- a/tests/components/humidity/test_trigger.py +++ b/tests/components/humidity/test_trigger.py @@ -11,18 +11,21 @@ from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY, ) +from homeassistant.components.humidity.trigger import TRIGGERS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.weather import ATTR_WEATHER_HUMIDITY from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_numerical_state_value_changed_trigger_states, @@ -66,6 +69,12 @@ async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "changed": TargetSupport.STANDARD, + "crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -90,6 +99,11 @@ async def test_humidity_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + # --- Sensor domain tests (value in state.state) --- diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index 073c03bf5b20a9..06b775b4f34e90 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.illuminance.condition import CONDITIONS from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -14,9 +15,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -43,6 +46,13 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _ILLUMINANCE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_detected": TargetSupport.STANDARD, + "is_not_detected": TargetSupport.STANDARD, + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -68,6 +78,11 @@ async def test_illuminance_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/illuminance/test_trigger.py b/tests/components/illuminance/test_trigger.py index bf3151c27f27e1..01a4b668475c1b 100644 --- a/tests/components/illuminance/test_trigger.py +++ b/tests/components/illuminance/test_trigger.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.illuminance.trigger import TRIGGERS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -16,11 +17,13 @@ from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, @@ -45,6 +48,14 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "detected": TargetSupport.STANDARD, + "cleared": TargetSupport.STANDARD, + "changed": TargetSupport.STANDARD, + "crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -71,6 +82,11 @@ async def test_illuminance_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + # --- Binary sensor detected/cleared tests --- diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 500a8894f68b29..3c26c39181e2b8 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.lawn_mower.condition import CONDITIONS from homeassistant.components.lawn_mower.const import LawnMowerActivity from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,15 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "lawn_mower") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_docked": TargetSupport.STANDARD, + "is_encountering_an_error": TargetSupport.STANDARD, + "is_mowing": TargetSupport.STANDARD, + "is_paused": TargetSupport.STANDARD, + "is_returning": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -53,6 +65,11 @@ async def test_lawn_mower_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("lawn_mower"), diff --git a/tests/components/lawn_mower/test_trigger.py b/tests/components/lawn_mower/test_trigger.py index ec3317f57018d0..5f8b740b660a9b 100644 --- a/tests/components/lawn_mower/test_trigger.py +++ b/tests/components/lawn_mower/test_trigger.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.lawn_mower import LawnMowerActivity +from homeassistant.components.lawn_mower.trigger import TRIGGERS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_target_entities, parametrize_trigger_states, @@ -26,6 +29,15 @@ async def target_lawn_mowers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "lawn_mower") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "returned_to_dock": TargetSupport.STANDARD, + "errored": TargetSupport.STANDARD, + "paused_mowing": TargetSupport.STANDARD, + "started_mowing": TargetSupport.STANDARD, + "started_returning": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -53,6 +65,11 @@ async def test_lawn_mower_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("lawn_mower"), diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 799b150c5659f5..e649023d92c698 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_attribute_condition_above_below_all, @@ -34,6 +37,13 @@ async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: _BRIGHTNESS_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_brightness": TargetSupport.STANDARD, + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -59,6 +69,11 @@ async def test_light_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("light"), diff --git a/tests/components/light/test_trigger.py b/tests/components/light/test_trigger.py index 765c34b1c2ad50..8a76faf7380e7d 100644 --- a/tests/components/light/test_trigger.py +++ b/tests/components/light/test_trigger.py @@ -5,16 +5,19 @@ import pytest from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light.trigger import TRIGGERS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, @@ -42,6 +45,14 @@ async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "brightness_changed": TargetSupport.STANDARD, + "brightness_crossed_threshold": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -73,6 +84,11 @@ async def test_light_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("light"), diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 0cf1bc138cd6fc..a1ba41e4bfc158 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1216,3 +1216,41 @@ async def test_adjacent_events_stay_on( state = hass.states.get(TEST_ENTITY) assert state.state == STATE_ON assert state.attributes["message"] == "Second" + + +ICS_WITH_STATUS = """BEGIN:VCALENDAR +BEGIN:VEVENT +SUMMARY:Bastille Day Party +DTSTART:19970714 +DTEND:19970715 +STATUS:{status} +END:VEVENT +END:VCALENDAR +""" + + +@pytest.mark.parametrize( + ("ics_content", "expected_status"), + [ + pytest.param( + ICS_WITH_STATUS.format(status="TENTATIVE"), "tentative", id="tentative" + ), + pytest.param( + ICS_WITH_STATUS.format(status="CONFIRMED"), "confirmed", id="confirmed" + ), + pytest.param( + ICS_WITH_STATUS.format(status="CANCELLED"), + None, + id="cancelled_is_not_reported", + ), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_event_status( + get_events: GetEventsFn, + expected_status: str | None, +) -> None: + """Test that the rfc5545 STATUS property is returned by the API.""" + events = await get_events("1997-07-13T00:00:00", "1997-07-16T00:00:00") + assert len(events) == 1 + assert events[0]["status"] == expected_status diff --git a/tests/components/lock/test_condition.py b/tests/components/lock/test_condition.py index 53829a93a7d1c5..80a6f2ee43d29c 100644 --- a/tests/components/lock/test_condition.py +++ b/tests/components/lock/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.lock.condition import CONDITIONS from homeassistant.components.lock.const import LockState from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,14 @@ async def target_locks(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "lock") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_jammed": TargetSupport.STANDARD, + "is_locked": TargetSupport.STANDARD, + "is_open": TargetSupport.STANDARD, + "is_unlocked": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -52,6 +63,11 @@ async def test_lock_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("lock"), diff --git a/tests/components/lock/test_trigger.py b/tests/components/lock/test_trigger.py index 947711784e24d0..3aea579e8f567d 100644 --- a/tests/components/lock/test_trigger.py +++ b/tests/components/lock/test_trigger.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.lock import DOMAIN, LockState +from homeassistant.components.lock.trigger import TRIGGERS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_target_entities, parametrize_trigger_states, @@ -26,6 +29,14 @@ async def target_locks(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "jammed": TargetSupport.STANDARD, + "locked": TargetSupport.STANDARD, + "opened": TargetSupport.STANDARD, + "unlocked": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -52,6 +63,11 @@ async def test_lock_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/mealie/snapshots/test_calendar.ambr b/tests/components/mealie/snapshots/test_calendar.ambr index fc252c48ecca2e..7589b663d0e242 100644 --- a/tests/components/mealie/snapshots/test_calendar.ambr +++ b/tests/components/mealie/snapshots/test_calendar.ambr @@ -44,6 +44,7 @@ 'start': dict({ 'date': '2024-01-22', }), + 'status': None, 'summary': 'Zoete aardappel curry traybake', 'uid': None, }), @@ -58,6 +59,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'uid': None, }), @@ -72,6 +74,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Pampered Chef Double Chocolate Mocha Trifle', 'uid': None, }), @@ -86,6 +89,7 @@ 'start': dict({ 'date': '2024-01-22', }), + 'status': None, 'summary': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'uid': None, }), @@ -100,6 +104,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'All-American Beef Stew Recipe', 'uid': None, }), @@ -114,6 +119,7 @@ 'start': dict({ 'date': '2024-01-23', }), + 'status': None, 'summary': 'Miso Udon Noodles with Spinach and Tofu', 'uid': None, }), @@ -128,6 +134,7 @@ 'start': dict({ 'date': '2024-01-21', }), + 'status': None, 'summary': 'Aquavite', 'uid': None, }), diff --git a/tests/components/media_player/test_condition.py b/tests/components/media_player/test_condition.py index 95bf10ef49ed6a..6cbb496bc08207 100644 --- a/tests/components/media_player/test_condition.py +++ b/tests/components/media_player/test_condition.py @@ -8,14 +8,17 @@ ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ) +from homeassistant.components.media_player.condition import CONDITIONS from homeassistant.components.media_player.const import MediaPlayerState from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -95,6 +98,18 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "media_player") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_muted": TargetSupport.STANDARD, + "is_not_playing": TargetSupport.STANDARD, + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, + "is_paused": TargetSupport.STANDARD, + "is_playing": TargetSupport.STANDARD, + "is_unmuted": TargetSupport.STANDARD, + "is_volume": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -125,6 +140,11 @@ async def test_media_player_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("media_player"), diff --git a/tests/components/media_player/test_trigger.py b/tests/components/media_player/test_trigger.py index dc3a2de673080a..914a5050d6e6a3 100644 --- a/tests/components/media_player/test_trigger.py +++ b/tests/components/media_player/test_trigger.py @@ -9,10 +9,12 @@ ATTR_MEDIA_VOLUME_MUTED, MediaPlayerState, ) +from homeassistant.components.media_player.trigger import TRIGGERS from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, @@ -20,6 +22,7 @@ assert_trigger_behavior_first, assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, @@ -93,6 +96,19 @@ def parametrize_muted_trigger_states( ) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "muted": TargetSupport.STANDARD, + "unmuted": TargetSupport.STANDARD, + "volume_changed": TargetSupport.STANDARD, + "volume_crossed_threshold": TargetSupport.STANDARD, + "paused_playing": TargetSupport.STANDARD, + "started_playing": TargetSupport.STANDARD, + "stopped_playing": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -129,6 +145,11 @@ async def test_media_player_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("media_player"), diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index dea9c435acbb6c..a61b9d2a77e1ea 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -4,6 +4,7 @@ import pytest +from homeassistant.components.moisture.condition import CONDITIONS from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, @@ -14,9 +15,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_numerical_condition_above_below_all, @@ -43,6 +46,13 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _MOISTURE_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_detected": TargetSupport.STANDARD, + "is_not_detected": TargetSupport.STANDARD, + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -68,6 +78,11 @@ async def test_moisture_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/moisture/test_trigger.py b/tests/components/moisture/test_trigger.py index 077d8b6395efcb..68cdd7c8dde2bd 100644 --- a/tests/components/moisture/test_trigger.py +++ b/tests/components/moisture/test_trigger.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.moisture.trigger import TRIGGERS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -15,12 +16,14 @@ from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_ignores_limit_entities_with_wrong_unit, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, @@ -45,6 +48,14 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: _CROSSED_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}} +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "detected": TargetSupport.STANDARD, + "cleared": TargetSupport.STANDARD, + "changed": TargetSupport.STANDARD, + "crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -71,6 +82,11 @@ async def test_moisture_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/motion/test_condition.py b/tests/components/motion/test_condition.py index 9e8a5f4f8104a8..14c9db73c7c6ec 100644 --- a/tests/components/motion/test_condition.py +++ b/tests/components/motion/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.motion.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_detected": TargetSupport.STANDARD, + "is_not_detected": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_motion_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/motion/test_trigger.py b/tests/components/motion/test_trigger.py index ad8de80e2e4b3e..56488326101d8e 100644 --- a/tests/components/motion/test_trigger.py +++ b/tests/components/motion/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.motion.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "detected": TargetSupport.STANDARD, + "cleared": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_motion_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/occupancy/test_condition.py b/tests/components/occupancy/test_condition.py index 446492f1e8a325..7d103429a79e88 100644 --- a/tests/components/occupancy/test_condition.py +++ b/tests/components/occupancy/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.occupancy.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_detected": TargetSupport.STANDARD, + "is_not_detected": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_occupancy_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/occupancy/test_trigger.py b/tests/components/occupancy/test_trigger.py index e4e84bdebe392c..3d2fe92213f4b4 100644 --- a/tests/components/occupancy/test_trigger.py +++ b/tests/components/occupancy/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.occupancy.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "detected": TargetSupport.STANDARD, + "cleared": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_occupancy_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index d095b9aeadccf4..92f9fab504b6e0 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.power.condition import CONDITIONS from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, assert_numerical_condition_unit_conversion, parametrize_numerical_condition_above_below_all, parametrize_numerical_condition_above_below_any, @@ -34,6 +37,11 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: } +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +65,11 @@ async def test_power_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), diff --git a/tests/components/power/test_trigger.py b/tests/components/power/test_trigger.py index ac96f363b4981f..e8bf0da4581132 100644 --- a/tests/components/power/test_trigger.py +++ b/tests/components/power/test_trigger.py @@ -4,16 +4,19 @@ import pytest +from homeassistant.components.power.trigger import TRIGGERS from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_state_value_changed_trigger_states, parametrize_numerical_state_value_crossed_threshold_trigger_states, parametrize_target_entities, @@ -39,6 +42,12 @@ async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "changed": TargetSupport.STANDARD, + "crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -63,6 +72,11 @@ async def test_power_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), diff --git a/tests/components/remote/test_condition.py b/tests/components/remote/test_condition.py index 6c888e6d9159f2..99362828bfb022 100644 --- a/tests/components/remote/test_condition.py +++ b/tests/components/remote/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.remote.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,12 @@ async def target_remotes(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "remote", domain_excluded="switch") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +58,11 @@ async def test_remote_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("remote"), diff --git a/tests/components/remote/test_trigger.py b/tests/components/remote/test_trigger.py index c5c07cbc24e96f..6e5a9f84cfa1c7 100644 --- a/tests/components/remote/test_trigger.py +++ b/tests/components/remote/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.remote import DOMAIN +from homeassistant.components.remote.trigger import TRIGGERS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_remotes(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "turned_on": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_remote_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/remote_calendar/snapshots/test_calendar.ambr b/tests/components/remote_calendar/snapshots/test_calendar.ambr index e372be5255c03d..470e3d08d221d6 100644 --- a/tests/components/remote_calendar/snapshots/test_calendar.ambr +++ b/tests/components/remote_calendar/snapshots/test_calendar.ambr @@ -12,6 +12,7 @@ 'start': dict({ 'dateTime': '2024-04-26T14:00:00-06:00', }), + 'status': None, 'summary': 'Uffe', 'uid': '040000008200E00074C5B7101A82E00800000000687C546B5596DA01000000000000000010000000309AE93C8C3A94489F90ADBEA30C2F2B', }), diff --git a/tests/components/satel_integra/test_alarm_control_panel.py b/tests/components/satel_integra/test_alarm_control_panel.py index 1a43b08d31d78b..01675b3eba32d0 100644 --- a/tests/components/satel_integra/test_alarm_control_panel.py +++ b/tests/components/satel_integra/test_alarm_control_panel.py @@ -22,6 +22,7 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -225,6 +226,28 @@ async def test_alarm_control_panel_disarming( mock_satel.clear_alarm.assert_awaited_once_with(MOCK_CODE, [1]) +async def test_alarm_control_panel_disarming_requires_code( + hass: HomeAssistant, + mock_satel: AsyncMock, + mock_config_entry_with_subentries: MockConfigEntry, +) -> None: + """Test disarming fails when the access code is missing.""" + await setup_integration(hass, mock_config_entry_with_subentries) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + ALARM_DOMAIN, + SERVICE_ALARM_DISARM, + {ATTR_ENTITY_ID: "alarm_control_panel.home"}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "missing_alarm_access_code" + mock_satel.disarm.assert_not_awaited() + mock_satel.clear_alarm.assert_not_awaited() + + async def test_alarm_panel_last_reported( hass: HomeAssistant, mock_satel: AsyncMock, diff --git a/tests/components/scene/test_trigger.py b/tests/components/scene/test_trigger.py index e716ea51bdc938..c65b5fc3366df7 100644 --- a/tests/components/scene/test_trigger.py +++ b/tests/components/scene/test_trigger.py @@ -4,13 +4,16 @@ import pytest +from homeassistant.components.scene.trigger import TRIGGERS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -23,6 +26,11 @@ async def target_scenes(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "scene") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "activated": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -46,6 +54,11 @@ async def test_scene_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("scene"), diff --git a/tests/components/schedule/test_condition.py b/tests/components/schedule/test_condition.py index 583059d1d2bdfb..35b0631db60547 100644 --- a/tests/components/schedule/test_condition.py +++ b/tests/components/schedule/test_condition.py @@ -4,15 +4,18 @@ import pytest +from homeassistant.components.schedule.condition import CONDITIONS from homeassistant.components.schedule.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -26,6 +29,12 @@ async def target_schedules(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_schedule_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/schedule/test_trigger.py b/tests/components/schedule/test_trigger.py index 6f2be5cb4ef21e..17576b9311d49c 100644 --- a/tests/components/schedule/test_trigger.py +++ b/tests/components/schedule/test_trigger.py @@ -13,17 +13,20 @@ CONF_TO, DOMAIN, ) +from homeassistant.components.schedule.trigger import TRIGGERS from homeassistant.const import CONF_ICON, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -36,6 +39,12 @@ async def target_schedules(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "block_started": TargetSupport.STANDARD, + "block_ended": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -60,6 +69,11 @@ async def test_schedule_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py index a4c2d68a12bb95..ee0449695f2587 100644 --- a/tests/components/select/test_condition.py +++ b/tests/components/select/test_condition.py @@ -6,16 +6,18 @@ import pytest import voluptuous as vol -from homeassistant.components.select.condition import CONF_OPTION +from homeassistant.components.select.condition import CONDITIONS, CONF_OPTION from homeassistant.const import CONF_ENTITY_ID, CONF_OPTIONS, CONF_TARGET from homeassistant.core import HomeAssistant from homeassistant.helpers.condition import async_validate_condition_config from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -36,6 +38,11 @@ async def target_input_selects(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "input_select") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_option_selected": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -59,6 +66,11 @@ async def test_select_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("select"), diff --git a/tests/components/select/test_trigger.py b/tests/components/select/test_trigger.py index b28efe80931ba7..ae0964d302b1ab 100644 --- a/tests/components/select/test_trigger.py +++ b/tests/components/select/test_trigger.py @@ -4,13 +4,16 @@ import pytest +from homeassistant.components.select.trigger import TRIGGERS from homeassistant.const import CONF_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -29,6 +32,11 @@ async def target_input_selects(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "input_select") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "selection_changed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -52,6 +60,11 @@ async def test_select_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + STATE_SEQUENCE = [ ( "select.selection_changed", diff --git a/tests/components/siren/test_condition.py b/tests/components/siren/test_condition.py index 8f7aa62844fe01..feccd0b7d18da7 100644 --- a/tests/components/siren/test_condition.py +++ b/tests/components/siren/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.siren.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,12 @@ async def target_sirens(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "siren", domain_excluded="switch") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +58,11 @@ async def test_siren_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("siren"), diff --git a/tests/components/siren/test_trigger.py b/tests/components/siren/test_trigger.py index 387520df02a9b7..4d15a8e8380508 100644 --- a/tests/components/siren/test_trigger.py +++ b/tests/components/siren/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.siren import DOMAIN +from homeassistant.components.siren.trigger import TRIGGERS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_sirens(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "turned_on": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_siren_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index b0325e2942991f..efc2b884ff4963 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.switch.condition import CONDITIONS from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -32,6 +35,12 @@ async def target_input_booleans(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "input_boolean") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -56,6 +65,11 @@ async def test_switch_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("switch"), diff --git a/tests/components/switch/test_trigger.py b/tests/components/switch/test_trigger.py index cbab958a80e772..1794c174b8e88f 100644 --- a/tests/components/switch/test_trigger.py +++ b/tests/components/switch/test_trigger.py @@ -5,16 +5,19 @@ import pytest from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch.trigger import TRIGGERS from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -46,6 +49,12 @@ async def target_input_booleans(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "input_boolean") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "turned_on": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -70,6 +79,11 @@ async def test_switch_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + # --- Switch domain tests --- diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index fa1289d95c16b4..6c2b1fcbbf3bf6 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.climate import HVACMode +from homeassistant.components.temperature.condition import CONDITIONS from homeassistant.components.weather import ATTR_WEATHER_TEMPERATURE_UNIT from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, assert_numerical_condition_unit_conversion, parametrize_numerical_attribute_condition_above_below_all, parametrize_numerical_attribute_condition_above_below_any, @@ -58,6 +61,11 @@ async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: } +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_value": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -81,6 +89,11 @@ async def test_temperature_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("sensor"), diff --git a/tests/components/temperature/test_trigger.py b/tests/components/temperature/test_trigger.py index e9187fde8a468c..4105b1a2bf963c 100644 --- a/tests/components/temperature/test_trigger.py +++ b/tests/components/temperature/test_trigger.py @@ -9,6 +9,7 @@ HVACMode, ) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.temperature.trigger import TRIGGERS from homeassistant.components.water_heater import ( ATTR_CURRENT_TEMPERATURE as WATER_HEATER_ATTR_CURRENT_TEMPERATURE, ) @@ -25,12 +26,14 @@ from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, arm_trigger, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_numerical_state_value_changed_trigger_states, @@ -81,6 +84,12 @@ async def target_weathers(hass: HomeAssistant) -> dict[str, list[str]]: _CHANGED_THRESHOLD = {"threshold": {"type": "any"}} +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "changed": TargetSupport.STANDARD, + "crossed_threshold": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -105,6 +114,11 @@ async def test_temperature_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + # --- Sensor domain tests (value in state.state) --- diff --git a/tests/components/test_shared_test_helpers.py b/tests/components/test_shared_test_helpers.py new file mode 100644 index 00000000000000..5479a7f8d4d306 --- /dev/null +++ b/tests/components/test_shared_test_helpers.py @@ -0,0 +1,408 @@ +"""Tests for the shared trigger/condition target-support test helpers. + +`assert_triggers_target_support` and `assert_conditions_target_support` (in +`tests.components.common`) let each modern trigger/condition test module certify, +against its own registry, that every trigger it tests either supports a user +target via the shared machinery (``STANDARD``), exposes no target +(``NONE``), or resolves a target with its own machinery (``CUSTOM``). The +per-domain axis reduction relies on that certification, so these tests pin that +the helpers actually reject the violations they are meant to catch, using +synthetic classes that each introduce exactly one defect. +""" + +from typing import Any, cast + +import pytest +import voluptuous as vol + +from homeassistant.const import CONF_TARGET, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ( + Condition, + ConditionConfig, + make_entity_state_condition, +) +from homeassistant.helpers.trigger import ( + Trigger, + TriggerConfig, + make_entity_target_state_trigger, +) + +from .common import ( + TargetSupport, + assert_conditions_target_support, + assert_triggers_target_support, +) + +# Valid baselines created by the public factories: proper entity-base classes that +# inherit the target machinery unmodified and carry the standard target slot. +_ValidTrigger = make_entity_target_state_trigger("test", STATE_ON) +_ValidCondition = make_entity_state_condition("test", STATE_ON) + +# A schema without a ``target`` marker (a class that does not expose a user target). +_NO_TARGET_SCHEMA = vol.Schema({vol.Optional("options"): dict}) +# A schema that does expose the standard user target slot. +_TARGET_SCHEMA = vol.Schema({vol.Required(CONF_TARGET): cv.TARGET_FIELDS}) + + +class _MachineryOverrideTrigger(_ValidTrigger): + """Overrides target-resolution machinery (``count_matches``).""" + + def count_matches(self, *args: Any, **kwargs: Any) -> Any: + """Trip the machinery-override check.""" + raise NotImplementedError + + +class _NoTargetSlotTrigger(_ValidTrigger): + """Entity-base class whose schema drops the standard ``target`` slot.""" + + _schema = _NO_TARGET_SCHEMA + + +class _MutatesConfigTargetTrigger(_ValidTrigger): + """``__init__`` rewrites the user target before delegating.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Rewrite the user target.""" + config.target = {CONF_TARGET: {}} + super().__init__(hass, config) + + +class _InitNoSuperTrigger(_ValidTrigger): + """``__init__`` does not delegate the config to super.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Fail to delegate to super.""" + # pylint: disable=super-init-not-called,unused-argument + self._hass = hass + + +class _RebuildsConfigTrigger(_ValidTrigger): + """``__init__`` rebuilds the config object.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Rebuild the config.""" + config = TriggerConfig(target=config.target) + super().__init__(hass, config) + + +class _AssignsTargetTrigger(_ValidTrigger): + """``__init__`` assigns ``self._target``.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Assign the resolved target directly.""" + super().__init__(hass, config) + self._target = config.target + + +class _WideningEntityFilterTrigger(_ValidTrigger): + """``entity_filter`` override that does not narrow the base result.""" + + def entity_filter(self, entities: set[str]) -> set[str]: + """Return the input unchanged instead of narrowing it.""" + return entities + + +class _CustomTargetTrigger(Trigger): + """Not an entity-base class; exposes a target slot but resolves it itself.""" + + _schema = _TARGET_SCHEMA + + +class _NestedBadInitTrigger(_MutatesConfigTargetTrigger): + """Clean delegating ``__init__`` above a parent that rewrites the target. + + The child initializer is hygienic on its own; the defect lives on the + parent, so this only fails if the MRO scan continues past the clean child. + """ + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + # pylint: disable=useless-parent-delegation + """Delegate cleanly to super.""" + super().__init__(hass, config) + + +class _NestedWideningEntityFilterTrigger(_WideningEntityFilterTrigger): + """Narrowing ``entity_filter`` above a parent that widens the base result. + + The child override narrows correctly; the defect lives on the parent, so + this only fails if the MRO scan continues past the clean child. + """ + + def entity_filter(self, entities: set[str]) -> set[str]: + """Narrow the base result.""" + return super().entity_filter(entities) & entities + + +class _MachineryOverrideCondition(_ValidCondition): + """Overrides target-resolution machinery (``_async_check``).""" + + async def _async_check(self, *args: Any, **kwargs: Any) -> Any: + """Trip the machinery-override check.""" + raise NotImplementedError + + +class _NoTargetSlotCondition(_ValidCondition): + """Entity-base class whose schema drops the standard ``target`` slot.""" + + _schema = _NO_TARGET_SCHEMA + + +class _MutatesConfigTargetCondition(_ValidCondition): + """``__init__`` rewrites the user target. + + ``ConditionConfig`` is not frozen, so this would rewrite the target at + runtime; the helper must catch it statically. + """ + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Rewrite the user target.""" + config.target = {CONF_TARGET: {}} + super().__init__(hass, config) + + +class _RebuildsConfigCondition(_ValidCondition): + """``__init__`` rebuilds the config object (exercises the ConditionConfig wiring).""" + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + """Rebuild the config.""" + config = ConditionConfig(target=config.target) + super().__init__(hass, config) + + +class _CustomTargetCondition(Condition): + """Not an entity-base class; exposes a target slot but resolves it itself.""" + + _schema = _TARGET_SCHEMA + + +class _NestedBadInitCondition(_MutatesConfigTargetCondition): + """Clean delegating ``__init__`` above a parent that rewrites the target. + + Mirrors ``_NestedBadInitTrigger`` on the condition side, where + ``_init_hygiene_violation`` is wired with ``config_cls="ConditionConfig"``. + """ + + def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: + # pylint: disable=useless-parent-delegation + """Delegate cleanly to super.""" + super().__init__(hass, config) + + +@pytest.mark.parametrize( + ("registry", "declaration", "match"), + [ + pytest.param( + {"x": _MachineryOverrideTrigger}, + {"x": TargetSupport.STANDARD}, + "overrides target machinery", + id="standard-overrides-machinery", + ), + pytest.param( + {"x": _NoTargetSlotTrigger}, + {"x": TargetSupport.STANDARD}, + "does not carry the standard", + id="standard-missing-target-slot", + ), + pytest.param( + {"x": _MutatesConfigTargetTrigger}, + {"x": TargetSupport.STANDARD}, + "must not assign to config.target", + id="standard-mutates-config-target", + ), + pytest.param( + {"x": _NestedBadInitTrigger}, + {"x": TargetSupport.STANDARD}, + "must not assign to config.target", + id="standard-nested-bad-init", + ), + pytest.param( + {"x": _InitNoSuperTrigger}, + {"x": TargetSupport.STANDARD}, + "must delegate the unmodified config", + id="standard-init-no-super", + ), + pytest.param( + {"x": _RebuildsConfigTrigger}, + {"x": TargetSupport.STANDARD}, + "must not rebuild the config object", + id="standard-rebuilds-config", + ), + pytest.param( + {"x": _AssignsTargetTrigger}, + {"x": TargetSupport.STANDARD}, + "must not assign self._target", + id="standard-assigns-target", + ), + pytest.param( + {"x": _WideningEntityFilterTrigger}, + {"x": TargetSupport.STANDARD}, + "entity_filter override must narrow", + id="standard-widens-entity-filter", + ), + pytest.param( + {"x": _NestedWideningEntityFilterTrigger}, + {"x": TargetSupport.STANDARD}, + "entity_filter override must narrow", + id="standard-nested-widening-filter", + ), + pytest.param( + {"x": _CustomTargetTrigger}, + {"x": TargetSupport.STANDARD}, + "does not subclass", + id="standard-not-entity-base", + ), + pytest.param( + {"x": _ValidTrigger}, + {"x": TargetSupport.NONE}, + "declared TargetSupport.NONE", + id="none-exposes-target-slot", + ), + pytest.param( + {"x": _NoTargetSlotTrigger}, + {"x": TargetSupport.CUSTOM}, + "declared TargetSupport.CUSTOM", + id="custom-missing-target-slot", + ), + pytest.param( + {"x": _ValidTrigger, "y": _ValidTrigger}, + {"x": TargetSupport.STANDARD}, + "registered but not declared", + id="registry-key-undeclared", + ), + pytest.param( + {"x": _ValidTrigger}, + {"x": TargetSupport.STANDARD, "y": TargetSupport.STANDARD}, + "declared but not registered", + id="declared-key-unregistered", + ), + pytest.param( + {"x": _ValidTrigger}, + {"x": cast(TargetSupport, "standard")}, + "invalid target-support declaration value", + id="raw-string-not-enum-member", + ), + ], +) +def test_assert_triggers_target_support_rejects( + registry: dict[str, type[Trigger]], + declaration: dict[str, TargetSupport], + match: str, +) -> None: + """Each synthetic defect is rejected by the trigger helper.""" + with pytest.raises(AssertionError, match=match): + assert_triggers_target_support(registry, declaration) + + +@pytest.mark.parametrize( + ("registry", "declaration", "match"), + [ + pytest.param( + {"x": _MachineryOverrideCondition}, + {"x": TargetSupport.STANDARD}, + "overrides target machinery", + id="standard-overrides-machinery", + ), + pytest.param( + {"x": _NoTargetSlotCondition}, + {"x": TargetSupport.STANDARD}, + "does not carry the standard", + id="standard-missing-target-slot", + ), + pytest.param( + {"x": _MutatesConfigTargetCondition}, + {"x": TargetSupport.STANDARD}, + "must not assign to config.target", + id="standard-mutates-config-target", + ), + pytest.param( + {"x": _NestedBadInitCondition}, + {"x": TargetSupport.STANDARD}, + "must not assign to config.target", + id="standard-nested-bad-init", + ), + pytest.param( + {"x": _RebuildsConfigCondition}, + {"x": TargetSupport.STANDARD}, + "must not rebuild the config object", + id="standard-rebuilds-config", + ), + pytest.param( + {"x": _CustomTargetCondition}, + {"x": TargetSupport.STANDARD}, + "does not subclass", + id="standard-not-entity-base", + ), + pytest.param( + {"x": _ValidCondition}, + {"x": TargetSupport.NONE}, + "declared TargetSupport.NONE", + id="none-exposes-target-slot", + ), + pytest.param( + {"x": _NoTargetSlotCondition}, + {"x": TargetSupport.CUSTOM}, + "declared TargetSupport.CUSTOM", + id="custom-missing-target-slot", + ), + pytest.param( + {"x": _ValidCondition, "y": _ValidCondition}, + {"x": TargetSupport.STANDARD}, + "registered but not declared", + id="registry-key-undeclared", + ), + pytest.param( + {"x": _ValidCondition}, + {"x": TargetSupport.STANDARD, "y": TargetSupport.STANDARD}, + "declared but not registered", + id="declared-key-unregistered", + ), + pytest.param( + {"x": _ValidCondition}, + {"x": cast(TargetSupport, "standard")}, + "invalid target-support declaration value", + id="raw-string-not-enum-member", + ), + ], +) +def test_assert_conditions_target_support_rejects( + registry: dict[str, type[Condition]], + declaration: dict[str, TargetSupport], + match: str, +) -> None: + """Each synthetic defect is rejected by the condition helper.""" + with pytest.raises(AssertionError, match=match): + assert_conditions_target_support(registry, declaration) + + +def test_assert_triggers_target_support_accepts_valid_declaration() -> None: + """A registry matching a correct declaration passes for all three states.""" + assert_triggers_target_support( + { + "standard": _ValidTrigger, + "none": _NoTargetSlotTrigger, + "custom": _CustomTargetTrigger, + }, + { + "standard": TargetSupport.STANDARD, + "none": TargetSupport.NONE, + "custom": TargetSupport.CUSTOM, + }, + ) + + +def test_assert_conditions_target_support_accepts_valid_declaration() -> None: + """A registry matching a correct declaration passes for all three states.""" + assert_conditions_target_support( + { + "standard": _ValidCondition, + "none": _NoTargetSlotCondition, + "custom": _CustomTargetCondition, + }, + { + "standard": TargetSupport.STANDARD, + "none": TargetSupport.NONE, + "custom": TargetSupport.CUSTOM, + }, + ) diff --git a/tests/components/text/test_condition.py b/tests/components/text/test_condition.py index 4728522d6e7102..4c2cf59b6a6799 100644 --- a/tests/components/text/test_condition.py +++ b/tests/components/text/test_condition.py @@ -4,7 +4,7 @@ import pytest -from homeassistant.components.text.condition import CONF_VALUE +from homeassistant.components.text.condition import CONDITIONS, CONF_VALUE from homeassistant.const import ( CONF_CONDITION, CONF_ENTITY_ID, @@ -18,9 +18,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -40,6 +42,11 @@ async def target_input_texts(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "input_text") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_equal_to": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -63,6 +70,11 @@ async def test_text_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + CONDITION_STATES_ANY = [ *parametrize_condition_states_any( condition="text.is_equal_to", diff --git a/tests/components/text/test_trigger.py b/tests/components/text/test_trigger.py index cdcb4c3ef22436..ac6499dfc7611b 100644 --- a/tests/components/text/test_trigger.py +++ b/tests/components/text/test_trigger.py @@ -6,13 +6,16 @@ from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN from homeassistant.components.text.const import DOMAIN +from homeassistant.components.text.trigger import TRIGGERS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from tests.components.common import ( BasicTriggerStateDescription, + TargetSupport, arm_trigger, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, set_or_remove_state, target_entities, @@ -128,6 +131,11 @@ async def target_input_texts(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, INPUT_TEXT_DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "changed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -151,6 +159,11 @@ async def test_text_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py index dcf3bacccb3609..dd430094774f28 100644 --- a/tests/components/timer/test_condition.py +++ b/tests/components/timer/test_condition.py @@ -5,13 +5,16 @@ import pytest from homeassistant.components.timer import STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED +from homeassistant.components.timer.condition import CONDITIONS from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,13 @@ async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "timer") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_active": TargetSupport.STANDARD, + "is_paused": TargetSupport.STANDARD, + "is_idle": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +60,11 @@ async def test_timer_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("timer"), diff --git a/tests/components/timer/test_trigger.py b/tests/components/timer/test_trigger.py index d2d587a05ab636..ce356747fe26bf 100644 --- a/tests/components/timer/test_trigger.py +++ b/tests/components/timer/test_trigger.py @@ -16,6 +16,7 @@ STATUS_IDLE, STATUS_PAUSED, ) +from homeassistant.components.timer.trigger import TRIGGERS from homeassistant.const import ( ATTR_LABEL_ID, CONF_ENTITY_ID, @@ -34,11 +35,13 @@ from tests.common import async_fire_time_changed from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -51,6 +54,16 @@ async def target_timers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "cancelled": TargetSupport.STANDARD, + "finished": TargetSupport.STANDARD, + "paused": TargetSupport.STANDARD, + "restarted": TargetSupport.STANDARD, + "started": TargetSupport.STANDARD, + "remaining_time_reached": TargetSupport.CUSTOM, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -79,6 +92,11 @@ async def test_timer_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py index 84ce817138d5d0..de65c77035f032 100644 --- a/tests/components/todo/test_condition.py +++ b/tests/components/todo/test_condition.py @@ -4,13 +4,16 @@ import pytest +from homeassistant.components.todo.condition import CONDITIONS from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -27,6 +30,12 @@ async def target_todos(hass: HomeAssistant) -> dict[str, list[str]]: _TODO_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 5}}} +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "all_completed": TargetSupport.STANDARD, + "incomplete": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -51,6 +60,11 @@ async def test_todo_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("todo"), diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 50b7665121f9e3..1a1d3c20776d52 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -78,6 +78,7 @@ def get_events_response(start: dict[str, str], end: dict[str, str]) -> dict[str, "uid": None, "recurrence_id": None, "rrule": None, + "status": None, } diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index b3df44bdac2d6c..e6ef47a0343fbc 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -20,6 +20,7 @@ 'start': dict({ 'date': '2022-01-06', }), + 'status': None, 'summary': 'Christmas tree pickup', 'uid': None, }), diff --git a/tests/components/update/test_condition.py b/tests/components/update/test_condition.py index f6e4808e96c1ba..50e64404e6730d 100644 --- a/tests/components/update/test_condition.py +++ b/tests/components/update/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.update.condition import CONDITIONS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -25,6 +28,12 @@ async def target_updates(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "update", domain_excluded="switch") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_available": TargetSupport.STANDARD, + "is_not_available": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +58,11 @@ async def test_update_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("update"), diff --git a/tests/components/update/test_trigger.py b/tests/components/update/test_trigger.py index 41929a16b0bca4..2884c8127e23f3 100644 --- a/tests/components/update/test_trigger.py +++ b/tests/components/update/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.update import DOMAIN +from homeassistant.components.update.trigger import TRIGGERS from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,11 @@ async def target_updates(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "became_available": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -49,6 +57,11 @@ async def test_update_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/vacuum/test_condition.py b/tests/components/vacuum/test_condition.py index 0bb538bc207f3e..573ac15e0a10bc 100644 --- a/tests/components/vacuum/test_condition.py +++ b/tests/components/vacuum/test_condition.py @@ -5,13 +5,16 @@ import pytest from homeassistant.components.vacuum import VacuumActivity +from homeassistant.components.vacuum.condition import CONDITIONS from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, other_states, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,15 @@ async def target_vacuums(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "vacuum") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_cleaning": TargetSupport.STANDARD, + "is_docked": TargetSupport.STANDARD, + "is_encountering_an_error": TargetSupport.STANDARD, + "is_paused": TargetSupport.STANDARD, + "is_returning": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -53,6 +65,11 @@ async def test_vacuum_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("vacuum"), diff --git a/tests/components/vacuum/test_trigger.py b/tests/components/vacuum/test_trigger.py index 9a74e54c10b03d..d266c006e4a3df 100644 --- a/tests/components/vacuum/test_trigger.py +++ b/tests/components/vacuum/test_trigger.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.vacuum import VacuumActivity +from homeassistant.components.vacuum.trigger import TRIGGERS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, other_states, parametrize_target_entities, parametrize_trigger_states, @@ -26,6 +29,15 @@ async def target_vacuums(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "vacuum") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "returned_to_dock": TargetSupport.STANDARD, + "errored": TargetSupport.STANDARD, + "paused_cleaning": TargetSupport.STANDARD, + "started_cleaning": TargetSupport.STANDARD, + "started_returning": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -53,6 +65,11 @@ async def test_vacuum_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("vacuum"), diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py index 11253b9ce3e0c8..d41cb40aca4ef4 100644 --- a/tests/components/valve/test_condition.py +++ b/tests/components/valve/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.valve import ATTR_IS_CLOSED +from homeassistant.components.valve.condition import CONDITIONS from homeassistant.components.valve.const import ValveState from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -26,6 +29,12 @@ async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "valve") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_open": TargetSupport.STANDARD, + "is_closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_valve_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("valve"), diff --git a/tests/components/valve/test_trigger.py b/tests/components/valve/test_trigger.py index 9299477102ce06..39d481dd02cda9 100644 --- a/tests/components/valve/test_trigger.py +++ b/tests/components/valve/test_trigger.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.valve import ATTR_IS_CLOSED, DOMAIN, ValveState +from homeassistant.components.valve.trigger import TRIGGERS from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -60,6 +63,12 @@ async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, DOMAIN) +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "closed": TargetSupport.STANDARD, + "opened": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -84,6 +93,11 @@ async def test_valve_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities(DOMAIN), diff --git a/tests/components/vibration/test_condition.py b/tests/components/vibration/test_condition.py index a81280df8147f8..187b2b2323f59d 100644 --- a/tests/components/vibration/test_condition.py +++ b/tests/components/vibration/test_condition.py @@ -4,14 +4,17 @@ import pytest +from homeassistant.components.vibration.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_detected": TargetSupport.STANDARD, + "is_not_detected": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_vibration_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/vibration/test_trigger.py b/tests/components/vibration/test_trigger.py index 98e355952dec43..535801b894e080 100644 --- a/tests/components/vibration/test_trigger.py +++ b/tests/components/vibration/test_trigger.py @@ -5,15 +5,18 @@ import pytest from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.vibration.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -26,6 +29,12 @@ async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "binary_sensor") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "detected": TargetSupport.STANDARD, + "cleared": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -50,6 +59,11 @@ async def test_vibration_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index 958f7e95f3ca69..cb92bfffeaf32e 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -12,6 +12,7 @@ STATE_HIGH_DEMAND, STATE_PERFORMANCE, ) +from homeassistant.components.water_heater.condition import CONDITIONS from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, @@ -23,9 +24,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, assert_numerical_condition_unit_conversion, parametrize_condition_states_all, parametrize_condition_states_any, @@ -63,6 +66,14 @@ async def target_water_heaters(hass: HomeAssistant) -> dict[str, list[str]]: } +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_off": TargetSupport.STANDARD, + "is_on": TargetSupport.STANDARD, + "is_operation_mode": TargetSupport.STANDARD, + "is_target_temperature": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -94,6 +105,11 @@ async def test_water_heater_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("water_heater"), diff --git a/tests/components/water_heater/test_trigger.py b/tests/components/water_heater/test_trigger.py index 6024bf4d2fb5f8..f786141c265d12 100644 --- a/tests/components/water_heater/test_trigger.py +++ b/tests/components/water_heater/test_trigger.py @@ -12,15 +12,18 @@ STATE_HIGH_DEMAND, STATE_PERFORMANCE, ) +from homeassistant.components.water_heater.trigger import TRIGGERS from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_numerical_attribute_changed_trigger_states, parametrize_numerical_attribute_crossed_threshold_trigger_states, parametrize_target_entities, @@ -56,6 +59,15 @@ async def target_water_heaters(hass: HomeAssistant) -> list[str]: } +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "operation_mode_changed": TargetSupport.STANDARD, + "target_temperature_changed": TargetSupport.STANDARD, + "target_temperature_crossed_threshold": TargetSupport.STANDARD, + "turned_off": TargetSupport.STANDARD, + "turned_on": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -98,6 +110,11 @@ async def test_water_heater_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("water_heater"), diff --git a/tests/components/window/test_condition.py b/tests/components/window/test_condition.py index 830d6dc343307c..2d926335439bb6 100644 --- a/tests/components/window/test_condition.py +++ b/tests/components/window/test_condition.py @@ -5,14 +5,17 @@ import pytest from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState +from homeassistant.components.window.condition import CONDITIONS from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, create_target_condition, parametrize_condition_states_all, parametrize_condition_states_any, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "is_closed": TargetSupport.STANDARD, + "is_open": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("condition_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_window_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + # --- binary_sensor tests --- diff --git a/tests/components/window/test_trigger.py b/tests/components/window/test_trigger.py index da5093308ab052..dbc0f62cfa08dd 100644 --- a/tests/components/window/test_trigger.py +++ b/tests/components/window/test_trigger.py @@ -6,15 +6,18 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState +from homeassistant.components.window.trigger import TRIGGERS from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -33,6 +36,12 @@ async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]: return await target_entities(hass, "cover") +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "opened": TargetSupport.STANDARD, + "closed": TargetSupport.STANDARD, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -57,6 +66,11 @@ async def test_window_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("trigger_target_config", "entity_id", "entities_in_target"), parametrize_target_entities("binary_sensor"), diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr index 045b4216a2f051..ef0877d289530c 100644 --- a/tests/components/withings/snapshots/test_calendar.ambr +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -20,6 +20,7 @@ 'start': dict({ 'dateTime': '2023-08-29T12:06:51-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -34,6 +35,7 @@ 'start': dict({ 'dateTime': '2023-08-31T01:08:27-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -48,6 +50,7 @@ 'start': dict({ 'dateTime': '2023-08-04T09:00:39-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -62,6 +65,7 @@ 'start': dict({ 'dateTime': '2023-09-22T16:33:55-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -76,6 +80,7 @@ 'start': dict({ 'dateTime': '2023-09-14T11:20:49-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -90,6 +95,7 @@ 'start': dict({ 'dateTime': '2023-09-22T16:55:53-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -104,6 +110,7 @@ 'start': dict({ 'dateTime': '2023-09-14T10:42:31-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -118,6 +125,7 @@ 'start': dict({ 'dateTime': '2023-10-09T00:12:49-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -132,6 +140,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:39:43-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -146,6 +155,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:13:23-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), @@ -160,6 +170,7 @@ 'start': dict({ 'dateTime': '2023-10-09T02:13:23-07:00', }), + 'status': None, 'summary': 'Walk', 'uid': None, }), diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py index e3e4d58e4f6586..3402a3f7305718 100644 --- a/tests/components/zone/test_condition.py +++ b/tests/components/zone/test_condition.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.zone import condition as zone_condition +from homeassistant.components.zone.condition import CONDITIONS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConditionError @@ -15,9 +16,11 @@ from tests.components.common import ( ConditionStateDescription, + TargetSupport, assert_condition_behavior_all, assert_condition_behavior_any, assert_condition_options_supported, + assert_conditions_target_support, parametrize_condition_states_all, parametrize_condition_states_any, parametrize_target_entities, @@ -334,6 +337,15 @@ async def test_zone_condition_falls_back_to_coordinates(hass: HomeAssistant) -> TARGET_ZONE = ZONE_HOME +_CONDITION_TARGET_SUPPORT: dict[str, TargetSupport] = { + "_": TargetSupport.NONE, + "in_zone": TargetSupport.STANDARD, + "not_in_zone": TargetSupport.STANDARD, + "occupancy_is_detected": TargetSupport.NONE, + "occupancy_is_not_detected": TargetSupport.NONE, +} + + @pytest.mark.parametrize( ( "condition_key", @@ -368,6 +380,11 @@ async def test_zone_condition_options_validation( ) +def test_condition_target_support() -> None: + """Certify the condition registry matches its declared target support.""" + assert_conditions_target_support(CONDITIONS, _CONDITION_TARGET_SUPPORT) + + @pytest.mark.parametrize( ("condition_key", "config"), [ diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 62201987d5b9a0..dc5d638ec06fc3 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import automation, zone +from homeassistant.components.zone.trigger import TRIGGERS from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, @@ -22,11 +23,13 @@ from tests.common import async_fire_time_changed, mock_component from tests.components.common import ( + TargetSupport, TriggerStateDescription, assert_trigger_behavior_all, assert_trigger_behavior_each, assert_trigger_behavior_first, assert_trigger_options_supported, + assert_triggers_target_support, parametrize_target_entities, parametrize_trigger_states, target_entities, @@ -526,6 +529,15 @@ async def test_unknown_zone( TRIGGER_ZONE = ZONE_HOME +_TRIGGER_TARGET_SUPPORT: dict[str, TargetSupport] = { + "_": TargetSupport.NONE, + "entered": TargetSupport.STANDARD, + "left": TargetSupport.STANDARD, + "occupancy_detected": TargetSupport.NONE, + "occupancy_cleared": TargetSupport.NONE, +} + + @pytest.mark.parametrize( ("trigger_key", "base_options", "supports_behavior", "supports_duration"), [ @@ -550,6 +562,11 @@ async def test_zone_trigger_options_validation( ) +def test_trigger_target_support() -> None: + """Certify the trigger registry matches its declared target support.""" + assert_triggers_target_support(TRIGGERS, _TRIGGER_TARGET_SUPPORT) + + @pytest.mark.parametrize("trigger_key", ["zone.entered", "zone.left"]) async def test_zone_trigger_rejects_non_zone_entity_id( hass: HomeAssistant, trigger_key: str diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 190a4785db3f15..a2b401398ec3fb 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -67,6 +67,7 @@ MAX_HISTORY_PRIMING_LOOKBACK, Condition, ConditionChecker, + ConditionConfig, EntityConditionBase, EntityNumericalConditionWithUnitBase, _async_get_condition_platform, @@ -6373,3 +6374,113 @@ async def test_state_condition_empty_state_value(hass: HomeAssistant) -> None: config = await condition.async_validate_condition_config(hass, config) test = await condition.async_from_config(hass, config) assert not test.async_check() + + +def _make_condition( + hass: HomeAssistant, domain_specs: Mapping[str, DomainSpec] +) -> EntityConditionBase: + """Create a minimal EntityConditionBase subclass with the given domain specs.""" + + class _SimpleCondition(EntityConditionBase): + """Minimal concrete condition for testing entity_filter.""" + + _domain_specs = domain_specs + + def is_valid_state(self, entity_state: State) -> bool: + """Accept any state.""" + return True + + config = ConditionConfig( + target={CONF_ENTITY_ID: []}, options={ATTR_BEHAVIOR: BEHAVIOR_ANY} + ) + return _SimpleCondition(hass, config) + + +async def test_condition_entity_filter_by_domain_only(hass: HomeAssistant) -> None: + """Test entity_filter includes entities matching domain, excludes others.""" + cond = _make_condition(hass, {"sensor": DomainSpec(), "switch": DomainSpec()}) + + entities = { + "sensor.temp", + "sensor.humidity", + "switch.light", + "light.bedroom", + "cover.garage", + } + result = cond.entity_filter(entities) + assert result == {"sensor.temp", "sensor.humidity", "switch.light"} + + +async def test_condition_entity_filter_by_device_class(hass: HomeAssistant) -> None: + """Test entity_filter filters by device_class when specified.""" + cond = _make_condition(hass, {"sensor": DomainSpec(device_class="humidity")}) + + hass.states.async_set("sensor.humidity_1", "50", {ATTR_DEVICE_CLASS: "humidity"}) + hass.states.async_set( + "sensor.temperature_1", "22", {ATTR_DEVICE_CLASS: "temperature"} + ) + hass.states.async_set("sensor.no_class", "10", {}) + + entities = {"sensor.humidity_1", "sensor.temperature_1", "sensor.no_class"} + result = cond.entity_filter(entities) + assert result == {"sensor.humidity_1"} + + +async def test_condition_entity_filter_device_class_unknown_entity( + hass: HomeAssistant, +) -> None: + """Test entity_filter excludes entities not in state machine or registry.""" + cond = _make_condition(hass, {"sensor": DomainSpec(device_class="humidity")}) + + entities = {"sensor.nonexistent"} + result = cond.entity_filter(entities) + assert result == set() + + +async def test_condition_entity_filter_multiple_domains_with_device_class( + hass: HomeAssistant, +) -> None: + """Test entity_filter with multiple domains, some with device_class filtering.""" + cond = _make_condition( + hass, + { + "climate": DomainSpec(value_source="current_humidity"), + "sensor": DomainSpec(device_class="humidity"), + "weather": DomainSpec(value_source="humidity"), + }, + ) + + hass.states.async_set("sensor.humidity", "60", {ATTR_DEVICE_CLASS: "humidity"}) + hass.states.async_set( + "sensor.temperature", "20", {ATTR_DEVICE_CLASS: "temperature"} + ) + hass.states.async_set("climate.hvac", "heat", {}) + hass.states.async_set("weather.home", "sunny", {}) + hass.states.async_set("light.bedroom", "on", {}) + + entities = { + "sensor.humidity", + "sensor.temperature", + "climate.hvac", + "weather.home", + "light.bedroom", + } + result = cond.entity_filter(entities) + # sensor.temperature excluded (wrong device_class), light.bedroom excluded + # (no matching domain). + assert result == {"sensor.humidity", "climate.hvac", "weather.home"} + + +async def test_condition_entity_filter_no_device_class_means_match_all_in_domain( + hass: HomeAssistant, +) -> None: + """Test that DomainSpec without device_class matches all entities in the domain.""" + cond = _make_condition(hass, {"cover": DomainSpec()}) + + hass.states.async_set("cover.door", "open", {ATTR_DEVICE_CLASS: "door"}) + hass.states.async_set("cover.garage", "closed", {ATTR_DEVICE_CLASS: "garage"}) + hass.states.async_set("cover.plain", "open", {}) + + entities = {"cover.door", "cover.garage", "cover.plain"} + result = cond.entity_filter(entities) + assert result == entities diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index af0114ce5545ca..56754aad397553 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -7,15 +7,24 @@ from typing import Any from unittest.mock import AsyncMock, patch -from aiohttp import ClientError -from multidict import CIMultiDict +from aiohttp import ( + ClientError, + ClientPayloadError, + ClientResponseError, + ContentTypeError, + RequestInfo, + ServerTimeoutError, +) +from multidict import CIMultiDict, CIMultiDictProxy import pytest +from yarl import URL from homeassistant import config_entries, data_entry_flow, setup from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + OAuth2TokenRequestConnectionError, OAuth2TokenRequestError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, @@ -24,7 +33,7 @@ from homeassistant.helpers.network import NoURLAvailableError from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import ClientSessionGenerator TEST_DOMAIN = "oauth2_test" @@ -35,6 +44,8 @@ ACCESS_TOKEN_2 = "mock-access-token-2" AUTHORIZE_URL = "https://example.como/auth/authorize" TOKEN_URL = "https://example.como/auth/token" +# Far enough ahead that a token carrying it always counts as unexpired. +FUTURE_EXPIRES_AT = 2000000000 MOCK_SECRET_TOKEN_URLSAFE = ( "token-" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -1106,6 +1117,356 @@ async def test_oauth_session_refresh_failure_exceptions( assert f"Token request for {TEST_DOMAIN} failed" in caplog.text +@pytest.mark.parametrize( + "raised", + [ + pytest.param(ClientError("Cannot connect"), id="client_error"), + pytest.param(ServerTimeoutError("Timeout"), id="timeout"), + ], +) +async def test_oauth_session_refresh_connection_error_is_transient( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + raised: Exception, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a token request that never gets a response is mapped to a transient error.""" + mock_integration(hass, MockModule(domain=TEST_DOMAIN)) + + flow_handler.async_register_implementation(hass, local_impl) + + aioclient_mock.post(TOKEN_URL, exc=raised) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(OAuth2TokenRequestConnectionError) as err, + ): + await session.async_ensure_token_valid() + + # Integrations rely on this to retry setup without mapping the error themselves. + assert isinstance(err.value, ConfigEntryNotReady) + assert err.value.translation_domain == HOMEASSISTANT_DOMAIN + assert err.value.translation_key == "oauth2_helper_refresh_transient" + assert f"Token request for {TEST_DOMAIN} got no response" in caplog.text + assert str(raised) in caplog.text + + +@pytest.mark.parametrize( + "response", + [ + pytest.param({"access_token": ACCESS_TOKEN_2}, id="missing_expires_in"), + pytest.param( + {"access_token": ACCESS_TOKEN_2, "expires_in": "soon"}, + id="unparsable_expires_in", + ), + pytest.param( + {"access_token": ACCESS_TOKEN_2, "expires_in": None}, + id="null_expires_in", + ), + ], +) +async def test_oauth_session_malformed_refresh_response_is_not_reauth( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + response: dict[str, Any], +) -> None: + """Test an unusable token response retries instead of blaming stored credentials.""" + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(TOKEN_URL, json=response) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(config_entry, "async_start_reauth_if_available") as start_reauth, + pytest.raises(OAuth2TokenRequestConnectionError) as err, + ): + await session.async_ensure_token_valid() + + assert isinstance(err.value, ConfigEntryNotReady) + assert err.value.translation_domain == HOMEASSISTANT_DOMAIN + assert err.value.translation_key == "oauth2_helper_refresh_transient" + # Relinking the account cannot fix a bad response, so it must not ask for it. + start_reauth.assert_not_called() + + +@pytest.mark.parametrize( + "response", + [ + pytest.param({"expires_in": 100}, id="no_access_token"), + pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"), + pytest.param({"access_token": "", "expires_in": 100}, id="blank_access_token"), + ], +) +async def test_oauth_session_refresh_without_access_token_is_rejected( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + response: dict[str, Any], +) -> None: + """Test a response with no usable access token is not merged over the old one.""" + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + aioclient_mock.post(TOKEN_URL, json=response) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + # The stale token must stay expired so the next attempt refreshes again. + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + assert config_entry.data["token"]["expires_at"] == 0 + + +@pytest.mark.parametrize( + "refreshed", + [ + pytest.param({"expires_in": 100}, id="no_access_token"), + pytest.param({"access_token": None, "expires_in": 100}, id="null_access_token"), + ], +) +async def test_oauth_session_custom_implementation_without_access_token( + hass: HomeAssistant, + refreshed: dict[str, Any], +) -> None: + """Test an implementation returning no usable access token is rejected.""" + + class BadImplementation(MockOAuth2Implementation): + """Implementation whose refresh skips the local token request.""" + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh a token.""" + return refreshed + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, BadImplementation() + ) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + + +@pytest.mark.parametrize( + "refreshed", + [ + pytest.param({"expires_at": FUTURE_EXPIRES_AT}, id="no_access_token"), + pytest.param({"access_token": ACCESS_TOKEN_2}, id="no_expires_at"), + ], +) +async def test_oauth_session_never_stores_an_unusable_token( + hass: HomeAssistant, + refreshed: dict[str, Any], +) -> None: + """Test the session checks the new token even when the refresh skips its own.""" + + class UncheckedImplementation(MockOAuth2Implementation): + """Implementation overriding the public refresh, so nothing maps for it.""" + + async def async_refresh_token(self, token: dict) -> dict: + """Refresh a token without the base class validation.""" + return refreshed + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "access_token": ACCESS_TOKEN_1, + "refresh_token": REFRESH_TOKEN, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, UncheckedImplementation() + ) + with pytest.raises(OAuth2TokenRequestConnectionError): + await session.async_ensure_token_valid() + + assert config_entry.data["token"]["access_token"] == ACCESS_TOKEN_1 + assert config_entry.data["token"]["expires_at"] == 0 + + +@pytest.mark.parametrize( + ("raised", "expected_exception"), + [ + pytest.param( + ClientPayloadError("Disconnected"), + OAuth2TokenRequestConnectionError, + id="payload_error", + ), + pytest.param( + ContentTypeError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + ), + OAuth2TokenRequestError, + id="content_type_error", + ), + ], +) +async def test_oauth_session_refresh_body_error_is_mapped( + hass: HomeAssistant, + flow_handler: type[config_entry_oauth2_flow.AbstractOAuth2FlowHandler], + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + raised: Exception, + expected_exception: type[Exception], +) -> None: + """Test a failure reading the token response body does not leak an aiohttp error.""" + mock_integration(hass, MockModule(domain=TEST_DOMAIN)) + + flow_handler.async_register_implementation(hass, local_impl) + + aioclient_mock.post(TOKEN_URL, json={}) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(AiohttpClientMockResponse, "json", side_effect=raised), + pytest.raises(expected_exception) as err, + ): + await session.async_ensure_token_valid() + + assert type(err.value) is expected_exception + assert isinstance(err.value, ConfigEntryNotReady) + + +@pytest.mark.parametrize( + ("raised", "expected_exception", "expected_base"), + [ + pytest.param( + ClientResponseError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + status=HTTPStatus.UNAUTHORIZED, + ), + OAuth2TokenRequestReauthError, + ConfigEntryAuthFailed, + id="reauth", + ), + pytest.param( + ClientResponseError( + RequestInfo( + url=URL(TOKEN_URL), + method="POST", + headers=CIMultiDictProxy(CIMultiDict()), + ), + (), + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ), + OAuth2TokenRequestTransientError, + ConfigEntryNotReady, + id="transient", + ), + pytest.param( + ClientError("Cannot connect"), + OAuth2TokenRequestConnectionError, + ConfigEntryNotReady, + id="connection_error", + ), + ], +) +async def test_refresh_maps_errors_from_custom_implementation( + hass: HomeAssistant, + raised: Exception, + expected_exception: type[Exception], + expected_base: type[Exception], +) -> None: + """Test an implementation issuing its own token request still raises mapped errors.""" + + class UnmappedImplementation(config_entry_oauth2_flow.LocalOAuth2Implementation): + """Implementation that lets raw aiohttp errors escape, like a custom one.""" + + async def _async_refresh_token(self, token: dict) -> dict: + raise raised + + implementation = UnmappedImplementation( + hass, TEST_DOMAIN, CLIENT_ID, CLIENT_SECRET, AUTHORIZE_URL, TOKEN_URL + ) + + with pytest.raises(expected_exception) as err: + await implementation.async_refresh_token({"refresh_token": REFRESH_TOKEN}) + + assert type(err.value) is expected_exception + assert isinstance(err.value, expected_base) + assert err.value.__cause__ is raised + + @pytest.mark.parametrize( "entry_state", [ @@ -1534,8 +1895,8 @@ async def test_oauth2_request_replaces_caller_authorization_header( ), pytest.param( 600, - config_entries.ConfigEntryState.SETUP_ERROR, - None, + config_entries.ConfigEntryState.SETUP_RETRY, + "oauth2_helper_refresh_failed", id="generic", ), ], @@ -1551,8 +1912,8 @@ async def test_token_error_handled_without_integration_mapping( ) -> None: """Test setup maps token refresh errors when the integration does not. - Only the transient and reauth subclasses carry config entry semantics, the - base error is left to the integration. + Every subclass carries config entry semantics, so the reauth subclass fails + setup while the others retry it. """ aioclient_mock.post(TOKEN_URL, status=status_code, json={})