diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 99d48f165be..b117a427646 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -134,6 +134,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "recorder_db_url": "str", "recorder_mock": "Recorder", "requests_mock": "requests_mock.Mocker", + "snapshot": "SnapshotAssertion", "tmp_path": "Path", } _TEST_FUNCTION_MATCH = TypeHintMatch( diff --git a/requirements_test.txt b/requirements_test.txt index 7784c749e09..34ededca1d4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -32,6 +32,7 @@ pytest-xdist==2.5.0 pytest==7.2.1 requests_mock==1.10.0 respx==0.20.1 +syrupy==4.0.0 tomli==2.0.1;python_version<"3.11" tqdm==4.64.0 types-atomicwrites==1.4.1 diff --git a/tests/components/bluetooth/snapshots/test_init.ambr b/tests/components/bluetooth/snapshots/test_init.ambr new file mode 100644 index 00000000000..70a7b7cbb48 --- /dev/null +++ b/tests/components/bluetooth/snapshots/test_init.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: test_issue_outdated_haos + IssueRegistryItemSnapshot({ + 'created': , + 'dismissed_version': None, + 'domain': 'bluetooth', + 'is_persistent': False, + 'issue_id': 'haos_outdated', + }) +# --- diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 7ab12b34641..5bb1eeb977c 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -8,6 +8,7 @@ from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -2873,6 +2874,7 @@ async def test_issue_outdated_haos( mock_bleak_scanner_start: MagicMock, one_adapter: None, operating_system_85: None, + snapshot: SnapshotAssertion, ) -> None: """Test we create an issue on outdated haos.""" entry = MockConfigEntry( @@ -2886,6 +2888,7 @@ async def test_issue_outdated_haos( registry = async_get_issue_registry(hass) issue = registry.async_get_issue(DOMAIN, "haos_outdated") assert issue is not None + assert issue == snapshot async def test_issue_outdated_haos_no_adapters( diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..46180994e61 --- /dev/null +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -0,0 +1,122 @@ +# serializer version: 1 +# name: test_full_user_flow_implementation + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': 'CN11A1A00001', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'mac': None, + 'port': 9123, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'elgato', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'mac': None, + 'port': 9123, + }), + 'disabled_by': None, + 'domain': 'elgato', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'CN11A1A00001', + 'unique_id': 'CN11A1A00001', + 'version': 1, + }), + 'title': 'CN11A1A00001', + 'type': , + 'version': 1, + }) +# --- +# name: test_full_zeroconf_flow_implementation + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'zeroconf', + 'unique_id': 'CN11A1A00001', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'port': 9123, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'elgato', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'port': 9123, + }), + 'disabled_by': None, + 'domain': 'elgato', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'CN11A1A00001', + 'unique_id': 'CN11A1A00001', + 'version': 1, + }), + 'title': 'CN11A1A00001', + 'type': , + 'version': 1, + }) +# --- +# name: test_zeroconf_during_onboarding + FlowResultSnapshot({ + 'context': dict({ + 'source': 'zeroconf', + 'unique_id': 'CN11A1A00001', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'port': 9123, + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'elgato', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'port': 9123, + }), + 'disabled_by': None, + 'domain': 'elgato', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'CN11A1A00001', + 'unique_id': 'CN11A1A00001', + 'version': 1, + }), + 'title': 'CN11A1A00001', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/elgato/snapshots/test_diagnostics.ambr b/tests/components/elgato/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a22dc07f717 --- /dev/null +++ b/tests/components/elgato/snapshots/test_diagnostics.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'info': dict({ + 'display_name': 'Frenck', + 'features': list([ + 'lights', + ]), + 'firmware_build_number': 192, + 'firmware_version': '1.0.3', + 'hardware_board_type': 53, + 'mac_address': None, + 'product_name': 'Elgato Key Light', + 'serial_number': 'CN11A1A00001', + 'wifi': None, + }), + 'state': dict({ + 'brightness': 21, + 'hue': None, + 'on': True, + 'saturation': None, + 'temperature': 297, + }), + }) +# --- diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fa22ca1dfab --- /dev/null +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -0,0 +1,415 @@ +# serializer version: 1 +# name: test_sensors[sensor.frenck_battery-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Frenck Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frenck_battery', + 'last_changed': , + 'last_updated': , + 'state': '78.57', + }) +# --- +# name: test_sensors[sensor.frenck_battery-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.frenck_battery-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.frenck_battery_voltage-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Frenck Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frenck_battery_voltage', + 'last_changed': , + 'last_updated': , + 'state': '3.86', + }) +# --- +# name: test_sensors[sensor.frenck_battery_voltage-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frenck_battery_voltage-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.frenck_charging_current-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Frenck Charging current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frenck_charging_current', + 'last_changed': , + 'last_updated': , + 'state': '3.008', + }) +# --- +# name: test_sensors[sensor.frenck_charging_current-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_charging_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging current', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_input_charge_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frenck_charging_current-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.frenck_charging_power-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Frenck Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frenck_charging_power', + 'last_changed': , + 'last_updated': , + 'state': '12.66', + }) +# --- +# name: test_sensors[sensor.frenck_charging_power-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_charge_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frenck_charging_power-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.frenck_charging_voltage-key-light-mini] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Frenck Charging voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frenck_charging_voltage', + 'last_changed': , + 'last_updated': , + 'state': '4.208', + }) +# --- +# name: test_sensors[sensor.frenck_charging_voltage-key-light-mini].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.frenck_charging_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging voltage', + 'platform': 'elgato', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GW24L1A02987_input_charge_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.frenck_charging_voltage-key-light-mini].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '202', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'GW24L1A02987', + ), + }), + 'is_new': False, + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light Mini', + 'name': 'Frenck', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.4 (229)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index e1551ecba4d..96da6ec4a4f 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -3,11 +3,12 @@ from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import zeroconf from homeassistant.components.elgato.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT, CONF_SOURCE +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -18,6 +19,7 @@ async def test_full_user_flow_implementation( hass: HomeAssistant, mock_elgato_config_flow: MagicMock, mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -33,14 +35,7 @@ async def test_full_user_flow_implementation( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "CN11A1A00001" - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_MAC: None, - CONF_PORT: 9123, - } - assert "result" in result2 - assert result2["result"].unique_id == "CN11A1A00001" + assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato_config_flow.info.mock_calls) == 1 @@ -50,6 +45,7 @@ async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, mock_elgato_config_flow: MagicMock, mock_setup_entry: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -81,14 +77,7 @@ async def test_full_zeroconf_flow_implementation( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "CN11A1A00001" - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_MAC: "AA:BB:CC:DD:EE:FF", - CONF_PORT: 9123, - } - assert "result" in result2 - assert result2["result"].unique_id == "CN11A1A00001" + assert result2 == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato_config_flow.info.mock_calls) == 1 @@ -204,6 +193,7 @@ async def test_zeroconf_during_onboarding( mock_elgato_config_flow: MagicMock, mock_setup_entry: AsyncMock, mock_onboarding: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf creates an entry during onboarding.""" result = await hass.config_entries.flow.async_init( @@ -221,14 +211,7 @@ async def test_zeroconf_during_onboarding( ) assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "CN11A1A00001" - assert result.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_MAC: "AA:BB:CC:DD:EE:FF", - CONF_PORT: 9123, - } - assert "result" in result - assert result["result"].unique_id == "CN11A1A00001" + assert result == snapshot assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_elgato_config_flow.info.mock_calls) == 1 diff --git a/tests/components/elgato/test_diagnostics.py b/tests/components/elgato/test_diagnostics.py index ac46ee4628f..1d9fa0eab0c 100644 --- a/tests/components/elgato/test_diagnostics.py +++ b/tests/components/elgato/test_diagnostics.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the Elgato integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -11,27 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "info": { - "display_name": "Frenck", - "firmware_build_number": 192, - "firmware_version": "1.0.3", - "hardware_board_type": 53, - "mac_address": None, - "product_name": "Elgato Key Light", - "serial_number": "CN11A1A00001", - "wifi": None, - "features": ["lights"], - }, - "state": { - "on": True, - "brightness": 21, - "hue": None, - "saturation": None, - "temperature": 297, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/elgato/test_sensor.py b/tests/components/elgato/test_sensor.py index fdc741652fe..6e45cfeb48a 100644 --- a/tests/components/elgato/test_sensor.py +++ b/tests/components/elgato/test_sensor.py @@ -1,23 +1,7 @@ """Tests for the Elgato sensor platform.""" import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.elgato.const import DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - EntityCategory, - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfPower, -) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -28,129 +12,34 @@ pytestmark = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "entity_id", + [ + "sensor.frenck_battery", + "sensor.frenck_battery_voltage", + "sensor.frenck_charging_current", + "sensor.frenck_charging_power", + "sensor.frenck_charging_voltage", + ], +) async def test_sensors( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test the Elgato sensors.""" - # Battery sensor - state = hass.states.get("sensor.frenck_battery") - assert state - assert state.state == "78.57" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Battery" - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert not state.attributes.get(ATTR_ICON) + state = hass.states.get(entity_id) + assert state == snapshot - entry = entity_registry.async_get("sensor.frenck_battery") - assert entry - assert entry.unique_id == "GW24L1A02987_battery" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert entry.options == {"sensor": {"suggested_display_precision": 0}} + entry = entity_registry.async_get(entity_id) + assert entry == snapshot - # Battery voltage sensor - state = hass.states.get("sensor.frenck_battery_voltage") - assert state - assert state.state == "3.86" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Battery voltage" - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert not state.attributes.get(ATTR_ICON) - - entry = entity_registry.async_get("sensor.frenck_battery_voltage") - assert entry - assert entry.unique_id == "GW24L1A02987_voltage" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert entry.options == { - "sensor": {"suggested_display_precision": 2}, - "sensor.private": { - "suggested_unit_of_measurement": UnitOfElectricPotential.VOLT - }, - } - - # Charging current sensor - state = hass.states.get("sensor.frenck_charging_current") - assert state - assert state.state == "3.008" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Charging current" - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE - ) - assert not state.attributes.get(ATTR_ICON) - - entry = entity_registry.async_get("sensor.frenck_charging_current") - assert entry - assert entry.unique_id == "GW24L1A02987_input_charge_current" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert entry.options == { - "sensor": {"suggested_display_precision": 2}, - "sensor.private": { - "suggested_unit_of_measurement": UnitOfElectricCurrent.AMPERE - }, - } - - # Charging power sensor - state = hass.states.get("sensor.frenck_charging_power") - assert state - assert state.state == "12.66" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Charging power" - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT - assert not state.attributes.get(ATTR_ICON) - - entry = entity_registry.async_get("sensor.frenck_charging_power") - assert entry - assert entry.unique_id == "GW24L1A02987_charge_power" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert entry.options == {"sensor": {"suggested_display_precision": 0}} - - # Charging voltage sensor - state = hass.states.get("sensor.frenck_charging_voltage") - assert state - assert state.state == "4.208" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck Charging voltage" - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT - ) - assert not state.attributes.get(ATTR_ICON) - - entry = entity_registry.async_get("sensor.frenck_charging_voltage") - assert entry - assert entry.unique_id == "GW24L1A02987_input_charge_voltage" - assert entry.entity_category == EntityCategory.DIAGNOSTIC - assert entry.options == { - "sensor": {"suggested_display_precision": 2}, - "sensor.private": { - "suggested_unit_of_measurement": UnitOfElectricPotential.VOLT - }, - } - - # Check if the entity is well registered in the device registry assert entry.device_id device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.configuration_url is None - assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") - } - assert device_entry.entry_type is None - assert device_entry.identifiers == {(DOMAIN, "GW24L1A02987")} - assert device_entry.manufacturer == "Elgato" - assert device_entry.model == "Elgato Key Light Mini" - assert device_entry.name == "Frenck" - assert device_entry.sw_version == "1.0.4 (229)" - assert device_entry.hw_version == "202" + assert device_entry == snapshot @pytest.mark.parametrize( diff --git a/tests/conftest.py b/tests/conftest.py index 7e58f1a6ffe..05dfcaa19ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,7 @@ import multidict import pytest import pytest_socket import requests_mock +from syrupy.assertion import SnapshotAssertion from homeassistant import core as ha, loader, runner, util from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY @@ -61,6 +62,7 @@ from homeassistant.util import dt as dt_util, location from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS +from .syrupy import HomeAssistantSnapshotExtension from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -1382,3 +1384,9 @@ def entity_registry(hass: HomeAssistant) -> er.EntityRegistry: def issue_registry(hass: HomeAssistant) -> ir.IssueRegistry: """Return the issue registry from the current hass instance.""" return ir.async_get(hass) + + +@pytest.fixture +def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion: + """Return snapshot assertion fixture with the Home Assistant extension.""" + return snapshot.use_extension(HomeAssistantSnapshotExtension) diff --git a/tests/syrupy.py b/tests/syrupy.py new file mode 100644 index 00000000000..ff3b0caa05a --- /dev/null +++ b/tests/syrupy.py @@ -0,0 +1,219 @@ +"""Home Assistant extension for Syrupy.""" +from __future__ import annotations + +from contextlib import suppress +import dataclasses +from pathlib import Path +from typing import Any + +import attr +import attrs +from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension +from syrupy.location import PyTestLocation +from syrupy.types import ( + PropertyFilter, + PropertyMatcher, + PropertyPath, + SerializableData, + SerializedData, +) +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import State +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) + + +class _ANY: + """Represent any value.""" + + def __repr__(self) -> str: + return "" + + +ANY = _ANY() + +__all__ = ["HomeAssistantSnapshotExtension"] + + +class AreaRegistryEntrySnapshot(dict): + """Tiny wrapper to represent an area registry entry in snapshots.""" + + +class ConfigEntrySnapshot(dict): + """Tiny wrapper to represent a config entry in snapshots.""" + + +class DeviceRegistryEntrySnapshot(dict): + """Tiny wrapper to represent a device registry entry in snapshots.""" + + +class EntityRegistryEntrySnapshot(dict): + """Tiny wrapper to represent an entity registry entry in snapshots.""" + + +class FlowResultSnapshot(dict): + """Tiny wrapper to represent a flow result in snapshots.""" + + +class IssueRegistryItemSnapshot(dict): + """Tiny wrapper to represent an entity registry entry in snapshots.""" + + +class StateSnapshot(dict): + """Tiny wrapper to represent an entity state in snapshots.""" + + +class HomeAssistantSnapshotSerializer(AmberDataSerializer): + """Home Assistant snapshot serializer for Syrupy. + + Handles special cases for Home Assistant data structures. + """ + + @classmethod + def _serialize( + cls, + data: SerializableData, + *, + depth: int = 0, + exclude: PropertyFilter | None = None, + matcher: PropertyMatcher | None = None, + path: PropertyPath = (), + visited: set[Any] | None = None, + ) -> SerializedData: + """Pre-process data before serializing. + + This allows us to handle specific cases for Home Assistant data structures. + """ + if isinstance(data, State): + serializable_data = cls._serializable_state(data) + elif isinstance(data, ar.AreaEntry): + serializable_data = cls._serializable_area_registry_entry(data) + elif isinstance(data, dr.DeviceEntry): + serializable_data = cls._serializable_device_registry_entry(data) + elif isinstance(data, er.RegistryEntry): + serializable_data = cls._serializable_entity_registry_entry(data) + elif isinstance(data, ir.IssueEntry): + serializable_data = cls._serializable_issue_registry_entry(data) + elif isinstance(data, dict) and "flow_id" in data and "handler" in data: + serializable_data = cls._serializable_flow_result(data) + elif isinstance(data, vol.Schema): + serializable_data = voluptuous_serialize.convert(data) + elif isinstance(data, ConfigEntry): + serializable_data = cls._serializable_config_entry(data) + elif dataclasses.is_dataclass(data): + serializable_data = dataclasses.asdict(data) + else: + serializable_data = data + with suppress(TypeError): + if attr.has(data): + serializable_data = attrs.asdict(data) + + return super()._serialize( + serializable_data, + depth=depth, + exclude=exclude, + matcher=matcher, + path=path, + visited=visited, + ) + + @classmethod + def _serializable_area_registry_entry(cls, data: ar.AreaEntry) -> SerializableData: + """Prepare a Home Assistant area registry entry for serialization.""" + serialized = AreaRegistryEntrySnapshot(attrs.asdict(data) | {"id": ANY}) + serialized.pop("_json_repr") + return serialized + + @classmethod + def _serializable_config_entry(cls, data: ConfigEntry) -> SerializableData: + """Prepare a Home Assistant config entry for serialization.""" + return ConfigEntrySnapshot(data.as_dict() | {"entry_id": ANY}) + + @classmethod + def _serializable_device_registry_entry( + cls, data: dr.DeviceEntry + ) -> SerializableData: + """Prepare a Home Assistant device registry entry for serialization.""" + serialized = DeviceRegistryEntrySnapshot( + attrs.asdict(data) + | { + "config_entries": ANY, + "id": ANY, + } + ) + if serialized["via_device_id"] is not None: + serialized["via_device_id"] = ANY + serialized.pop("_json_repr") + return serialized + + @classmethod + def _serializable_entity_registry_entry( + cls, data: er.RegistryEntry + ) -> SerializableData: + """Prepare a Home Assistant entity registry entry for serialization.""" + serialized = EntityRegistryEntrySnapshot( + attrs.asdict(data) + | { + "config_entry_id": ANY, + "device_id": ANY, + "id": ANY, + } + ) + serialized.pop("_json_repr") + return serialized + + @classmethod + def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: + """Prepare a Home Assistant flow result for serialization.""" + return FlowResultSnapshot(data | {"flow_id": ANY}) + + @classmethod + def _serializable_issue_registry_entry( + cls, data: ir.IssueEntry + ) -> SerializableData: + """Prepare a Home Assistant issue registry entry for serialization.""" + return IssueRegistryItemSnapshot(data.to_json() | {"created": ANY}) + + @classmethod + def _serializable_state(cls, data: State) -> SerializableData: + """Prepare a Home Assistant State for serialization.""" + return StateSnapshot( + data.as_dict() + | { + "context": ANY, + "last_changed": ANY, + "last_updated": ANY, + } + ) + + +class HomeAssistantSnapshotExtension(AmberSnapshotExtension): + """Home Assistant extension for Syrupy.""" + + VERSION = "1" + """Current version of serialization format. + + Need to be bumped when we change the HomeAssistantSnapshotSerializer. + """ + + serializer_class: type[AmberDataSerializer] = HomeAssistantSnapshotSerializer + + @classmethod + def dirname(cls, *, test_location: PyTestLocation) -> str: + """Return the directory for the snapshot files. + + Syrupy, by default, uses the `__snapshosts__` directory in the same + folder as the test file. For Home Assistant, this is changed to just + `snapshots` in the same folder as the test file, to match our `fixtures` + folder structure. + """ + test_dir = Path(test_location.filepath).parent + return str(test_dir.joinpath("snapshots"))