From ba70efec2e78807c7b215b3e1f16a3a44d3050c8 Mon Sep 17 00:00:00 2001 From: Pavel Shchors Date: Tue, 3 Mar 2026 21:52:07 +0100 Subject: [PATCH 1/5] Add resrobot sensor migration --- custom_components/hasl3/__init__.py | 99 ++++++++++++++++++++++++++++- custom_components/hasl3/const.py | 1 + 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/custom_components/hasl3/__init__.py b/custom_components/hasl3/__init__.py index bacf5f3..dc0dff0 100644 --- a/custom_components/hasl3/__init__.py +++ b/custom_components/hasl3/__init__.py @@ -1,8 +1,11 @@ import logging from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + entity_registry as er, +) from .const import ( CONF_INTEGRATION_ID, @@ -12,6 +15,14 @@ SENSOR_DEPARTURE, SENSOR_ROUTE, SENSOR_STATUS, + SENSOR_RRARR, + SENSOR_RRDEP, + SENSOR_RRROUTE, + SENSOR_RESROBOT_ARRIVAL, + SENSOR_RESROBOT_DEPARTURE, + SENSOR_RESROBOT_ROUTE, + CONF_RR_KEY, + SERVICE_RESROBOT_KEY, ) from .sensors.departure import async_setup_coordinator as setup_departure_coordinator from .sensors.route import async_setup_coordinator as setup_route_coordinator @@ -21,6 +32,8 @@ from .services.sl_find_trip_id import register as register_sl_find_trip_id from .services.sl_find_trip_pos import register as register_sl_find_trip_pos +from types import MappingProxyType + logger = logging.getLogger(f"custom_components.{DOMAIN}.core") @@ -30,6 +43,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigEntry): if DOMAIN not in hass.data: hass.data.setdefault(DOMAIN, {}) + + await async_migrate_integration(hass) logger.debug("[setup] Registering services") register_sl_find_location(hass) @@ -143,3 +158,85 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN].pop(entry.entry_id, None) return unload_ok + + +# custom migration to convert resrobot entries to main entry with subentries +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + NEW_VERSION = 5 + + entries = hass.config_entries.async_entries(DOMAIN) + + # We migrate only resrobot entries + if not any(entry.version < NEW_VERSION for entry in entries): + return + entries = [entry for entry in entries if ( + any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRARR for entry in entries) + or any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRDEP for entry in entries) + or any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRROUTE for entry in entries) + )] + if entries.count == 0: + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + + for entry in entries: + use_existing = False + + subentry_type=map_RR_entry_to_subentry(entry.data[CONF_INTEGRATION_TYPE]) + subentry_data = entry.data.copy() + # subentry doesn't need the api key + del subentry_data[CONF_RR_KEY] + subentry = ConfigSubentry( + data=MappingProxyType(subentry_data), + subentry_type=subentry_type, + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_RR_KEY] not in api_keys_entries: + use_existing = True + # we save the whole original entry here, we will update it in the end + api_keys_entries[entry.data[CONF_RR_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_RR_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + sensor_entity_id = entity_registry.async_get_entity_id( + subentry_type, + DOMAIN, + entry.entry_id, + ) + if sensor_entity_id is not None: + entity_registry.async_update_entity( + entity_id=sensor_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + entry_data=dict() + entry_data[CONF_RR_KEY] = entry.data[CONF_RR_KEY] + entry_data[CONF_INTEGRATION_TYPE] = SERVICE_RESROBOT_KEY + hass.config_entries.async_update_entry( + entry, + title=SERVICE_RESROBOT_KEY, + data=entry_data, + options={}, + version=NEW_VERSION, + ) + +def map_RR_entry_to_subentry(entry_name: str) -> str: + if entry_name == SENSOR_RRDEP: + return SENSOR_RESROBOT_DEPARTURE + elif entry_name == SENSOR_RRARR: + return SENSOR_RESROBOT_ARRIVAL + elif entry_name == SENSOR_RRROUTE: + return SENSOR_RESROBOT_ROUTE + else: + # raise exception? + return "" + diff --git a/custom_components/hasl3/const.py b/custom_components/hasl3/const.py index 02346f2..99247bf 100644 --- a/custom_components/hasl3/const.py +++ b/custom_components/hasl3/const.py @@ -22,6 +22,7 @@ SENSOR_RRDEP = "Resrobot Departures" SENSOR_RRARR = "Resrobot Arrivals" +SENSOR_RRROUTE = 'Resrobot Route Sensor' SENSOR_STATUS = "status_v2" SENSOR_ROUTE = "route_v2" SENSOR_DEPARTURE = "departure_v2" From f61fd35114e6840eb8e07736421a11fe977e3895 Mon Sep 17 00:00:00 2001 From: Pavel Date: Tue, 10 Mar 2026 21:37:53 +0100 Subject: [PATCH 2/5] Add migration tests --- custom_components/hasl3/__init__.py | 20 +-- pytest.ini | 2 + tests/__init__.py | 1 + tests/conftest.py | 3 + tests/test_migration_resrobot.py | 213 ++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_migration_resrobot.py diff --git a/custom_components/hasl3/__init__.py b/custom_components/hasl3/__init__.py index dc0dff0..dbb6149 100644 --- a/custom_components/hasl3/__init__.py +++ b/custom_components/hasl3/__init__.py @@ -166,16 +166,18 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: NEW_VERSION = 5 entries = hass.config_entries.async_entries(DOMAIN) - + # We migrate only resrobot entries if not any(entry.version < NEW_VERSION for entry in entries): return - entries = [entry for entry in entries if ( - any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRARR for entry in entries) - or any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRDEP for entry in entries) - or any(entry.data[CONF_INTEGRATION_TYPE] == SENSOR_RRROUTE for entry in entries) - )] - if entries.count == 0: + + entries = [ + entry + for entry in entries + if entry.data.get(CONF_INTEGRATION_TYPE) + in (SENSOR_RRARR, SENSOR_RRDEP, SENSOR_RRROUTE) + ] + if not entries: return api_keys_entries: dict[str, ConfigEntry] = {} @@ -186,6 +188,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: subentry_type=map_RR_entry_to_subentry(entry.data[CONF_INTEGRATION_TYPE]) subentry_data = entry.data.copy() + subentry_data[CONF_INTEGRATION_TYPE] = subentry_type # subentry doesn't need the api key del subentry_data[CONF_RR_KEY] subentry = ConfigSubentry( @@ -203,7 +206,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: hass.config_entries.async_add_subentry(parent_entry, subentry) sensor_entity_id = entity_registry.async_get_entity_id( - subentry_type, + SENSOR_DOMAIN, DOMAIN, entry.entry_id, ) @@ -240,3 +243,4 @@ def map_RR_entry_to_subentry(entry_name: str) -> str: # raise exception? return "" + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..f340014 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..faba5e1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the HASL integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..278cc87 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +"""Pytest configuration for HASL integration tests.""" + +pytest_plugins = "pytest_homeassistant_custom_component" diff --git a/tests/test_migration_resrobot.py b/tests/test_migration_resrobot.py new file mode 100644 index 0000000..fc2e77a --- /dev/null +++ b/tests/test_migration_resrobot.py @@ -0,0 +1,213 @@ +"""Tests for Resrobot entry migration.""" + +import pytest +from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.hasl3 import async_migrate_integration +from custom_components.hasl3.const import ( + CONF_DESTINATION, + CONF_INTEGRATION_TYPE, + CONF_RR_KEY, + CONF_SOURCE, + DOMAIN, + SENSOR_RRARR, + SENSOR_RRDEP, + SENSOR_RRROUTE, + SENSOR_RESROBOT_ARRIVAL, + SENSOR_RESROBOT_DEPARTURE, + SENSOR_RESROBOT_ROUTE, + SENSOR_STATUS, + SERVICE_RESROBOT_KEY, +) + +NEW_VERSION = 5 + + +def _legacy_rr_entry( + hass, + *, + title: str, + entry_id: str, + rr_key: str, + integration_type: str, + data_extra: dict | None = None, + version: int = 4, +): + data = { + CONF_INTEGRATION_TYPE: integration_type, + CONF_RR_KEY: rr_key, + **(data_extra or {}), + } + entry = MockConfigEntry( + domain=DOMAIN, + title=title, + data=data, + entry_id=entry_id, + version=version, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.mark.asyncio +async def test_migrate_resrobot_single_key_creates_parent_and_subentries(hass): + entry_dep = _legacy_rr_entry( + hass, + title="RR Departures", + entry_id="rrdep", + rr_key="key-1", + integration_type=SENSOR_RRDEP, + data_extra={CONF_SOURCE: "1001"}, + ) + _legacy_rr_entry( + hass, + title="RR Arrivals", + entry_id="rrarr", + rr_key="key-1", + integration_type=SENSOR_RRARR, + data_extra={CONF_DESTINATION: "2002"}, + ) + _legacy_rr_entry( + hass, + title="RR Route", + entry_id="rrroute", + rr_key="key-1", + integration_type=SENSOR_RRROUTE, + data_extra={CONF_SOURCE: "1001", CONF_DESTINATION: "2002"}, + ) + + await async_migrate_integration(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + parent = entries[0] + assert parent.entry_id == entry_dep.entry_id + assert parent.data == { + CONF_RR_KEY: "key-1", + CONF_INTEGRATION_TYPE: SERVICE_RESROBOT_KEY, + } + assert parent.options == {} + assert parent.version == NEW_VERSION + + subentries = list(parent.subentries.values()) + assert len(subentries) == 3 + + subentry_types = {subentry.data[CONF_INTEGRATION_TYPE] for subentry in subentries} + assert subentry_types == { + SENSOR_RESROBOT_DEPARTURE, + SENSOR_RESROBOT_ARRIVAL, + SENSOR_RESROBOT_ROUTE, + } + + for subentry in subentries: + assert CONF_RR_KEY not in subentry.data + assert subentry.title in {"RR Departures", "RR Arrivals", "RR Route"} + + +@pytest.mark.asyncio +async def test_migrate_resrobot_multiple_keys_grouped(hass): + _legacy_rr_entry( + hass, + title="RR Departures A", + entry_id="rrdep_a", + rr_key="key-a", + integration_type=SENSOR_RRDEP, + data_extra={CONF_SOURCE: "1001"}, + ) + _legacy_rr_entry( + hass, + title="RR Arrivals A", + entry_id="rrarr_a", + rr_key="key-a", + integration_type=SENSOR_RRARR, + data_extra={CONF_DESTINATION: "2002"}, + ) + _legacy_rr_entry( + hass, + title="RR Departures B", + entry_id="rrdep_b", + rr_key="key-b", + integration_type=SENSOR_RRDEP, + data_extra={CONF_SOURCE: "3003"}, + ) + + await async_migrate_integration(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + by_key = {entry.data[CONF_RR_KEY]: entry for entry in entries} + assert set(by_key) == {"key-a", "key-b"} + + assert len(by_key["key-a"].subentries) == 2 + assert len(by_key["key-b"].subentries) == 1 + + +@pytest.mark.asyncio +async def test_migrate_resrobot_updates_entity_registry(hass): + entry_dep = _legacy_rr_entry( + hass, + title="RR Departures", + entry_id="rrdep", + rr_key="key-1", + integration_type=SENSOR_RRDEP, + data_extra={CONF_SOURCE: "1001"}, + ) + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + entry_dep.entry_id, + suggested_object_id="rrdep", + ) + + await async_migrate_integration(hass) + + parent = hass.config_entries.async_entries(DOMAIN)[0] + subentry = next(iter(parent.subentries.values())) + updated = entity_registry.async_get(entity.entity_id) + + assert updated.config_entry_id == parent.entry_id + assert updated.config_subentry_id == subentry.subentry_id + assert updated.unique_id == subentry.subentry_id + + +@pytest.mark.asyncio +async def test_migrate_resrobot_idempotent_when_version_is_new(hass): + entry = _legacy_rr_entry( + hass, + title="RR Departures", + entry_id="rrdep", + rr_key="key-1", + integration_type=SENSOR_RRDEP, + data_extra={CONF_SOURCE: "1001"}, + version=NEW_VERSION, + ) + + await async_migrate_integration(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries == [entry] + assert entries[0].data[CONF_INTEGRATION_TYPE] == SENSOR_RRDEP + + +@pytest.mark.asyncio +async def test_migrate_non_resrobot_entries_untouched(hass): + entry = MockConfigEntry( + domain=DOMAIN, + title="Status", + data={CONF_INTEGRATION_TYPE: SENSOR_STATUS}, + entry_id="status", + version=4, + ) + entry.add_to_hass(hass) + + await async_migrate_integration(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries == [entry] + assert entries[0].data[CONF_INTEGRATION_TYPE] == SENSOR_STATUS From 297b1671fa5523fe7e854999cb06cc3dd6b4f24c Mon Sep 17 00:00:00 2001 From: Pavel Shchors Date: Sat, 21 Mar 2026 17:01:44 +0100 Subject: [PATCH 3/5] Add device migration --- custom_components/hasl3/__init__.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/custom_components/hasl3/__init__.py b/custom_components/hasl3/__init__.py index dbb6149..a15fb18 100644 --- a/custom_components/hasl3/__init__.py +++ b/custom_components/hasl3/__init__.py @@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import ( entity_registry as er, + device_registry as dr, ) from .const import ( @@ -168,7 +169,9 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: entries = hass.config_entries.async_entries(DOMAIN) # We migrate only resrobot entries - if not any(entry.version < NEW_VERSION for entry in entries): + old_versions = list(range(NEW_VERSION)) + old_versions = set([*old_versions, *[str(n) for n in old_versions]]) + if not any(entry.version in old_versions for entry in entries): return entries = [ @@ -182,6 +185,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: api_keys_entries: dict[str, ConfigEntry] = {} entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) for entry in entries: use_existing = False @@ -210,6 +214,11 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: DOMAIN, entry.entry_id, ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if sensor_entity_id is not None: entity_registry.async_update_entity( entity_id=sensor_entity_id, @@ -217,6 +226,19 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: config_subentry_id=subentry.subentry_id, new_unique_id=subentry.subentry_id, ) + + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) @@ -242,5 +264,3 @@ def map_RR_entry_to_subentry(entry_name: str) -> str: else: # raise exception? return "" - - From d89a08de027e79b18022b1b42e996867a1cecbaf Mon Sep 17 00:00:00 2001 From: Pavel Shchors Date: Sat, 21 Mar 2026 20:01:13 +0100 Subject: [PATCH 4/5] Update migration after testing --- custom_components/hasl3/__init__.py | 55 +++++++++++++++++------------ 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/custom_components/hasl3/__init__.py b/custom_components/hasl3/__init__.py index a15fb18..e1ff341 100644 --- a/custom_components/hasl3/__init__.py +++ b/custom_components/hasl3/__init__.py @@ -1,4 +1,5 @@ import logging +from typing import Any from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigSubentry @@ -11,6 +12,10 @@ from .const import ( CONF_INTEGRATION_ID, CONF_INTEGRATION_TYPE, + CONF_SOURCE, + CONF_DESTINATION, + CONF_SITE_ID, + DEVICE_GUID, DOMAIN, SCHEMA_VERSION, SENSOR_DEPARTURE, @@ -23,6 +28,11 @@ SENSOR_RESROBOT_DEPARTURE, SENSOR_RESROBOT_ROUTE, CONF_RR_KEY, + CONF_API_KEY, + CONF_SENSOR, + CONF_SCAN_INTERVAL, + CONF_SOURCE_ID, + CONF_DESTINATION_ID, SERVICE_RESROBOT_KEY, ) from .sensors.departure import async_setup_coordinator as setup_departure_coordinator @@ -33,7 +43,7 @@ from .services.sl_find_trip_id import register as register_sl_find_trip_id from .services.sl_find_trip_pos import register as register_sl_find_trip_pos -from types import MappingProxyType +from uuid import uuid4 logger = logging.getLogger(f"custom_components.{DOMAIN}.core") @@ -191,15 +201,25 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: use_existing = False subentry_type=map_RR_entry_to_subentry(entry.data[CONF_INTEGRATION_TYPE]) - subentry_data = entry.data.copy() - subentry_data[CONF_INTEGRATION_TYPE] = subentry_type - # subentry doesn't need the api key - del subentry_data[CONF_RR_KEY] + subentry_data: dict[str, Any] = { + CONF_INTEGRATION_TYPE: subentry_type, + CONF_SCAN_INTERVAL: entry.data[CONF_SCAN_INTERVAL], + CONF_SENSOR: entry.data[CONF_SENSOR] + } + + if subentry_type == SENSOR_RESROBOT_DEPARTURE: + subentry_data[CONF_SOURCE] = entry.data[CONF_SITE_ID] + elif subentry_type == SENSOR_RESROBOT_ARRIVAL: + subentry_data[CONF_DESTINATION] = entry.data[CONF_SITE_ID] + elif subentry_type == SENSOR_RESROBOT_ROUTE: + subentry_data[CONF_SOURCE] = entry.data[CONF_SOURCE_ID] + subentry_data[CONF_DESTINATION] = entry.data[CONF_DESTINATION_ID] + subentry = ConfigSubentry( - data=MappingProxyType(subentry_data), + data=subentry_data, subentry_type=subentry_type, title=entry.title, - unique_id=None, + unique_id=str(uuid4()), ) if entry.data[CONF_RR_KEY] not in api_keys_entries: use_existing = True @@ -216,8 +236,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} + identifiers={(DOMAIN, DEVICE_GUID)} ) + if device is not None: + device_registry.async_remove_device( + device_id=device.id + ) if sensor_entity_id is not None: entity_registry.async_update_entity( @@ -226,25 +250,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: config_subentry_id=subentry.subentry_id, new_unique_id=subentry.subentry_id, ) - - if device is not None: - device_registry.async_update_device( - device.id, - new_identifiers={(DOMAIN, subentry.subentry_id)}, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, - ) - if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - remove_config_entry_id=entry.entry_id, - ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: entry_data=dict() - entry_data[CONF_RR_KEY] = entry.data[CONF_RR_KEY] + entry_data[CONF_API_KEY] = entry.data[CONF_RR_KEY] entry_data[CONF_INTEGRATION_TYPE] = SERVICE_RESROBOT_KEY hass.config_entries.async_update_entry( entry, From a71fbafbbd8a4202559c055c3ff277f7930fd838 Mon Sep 17 00:00:00 2001 From: Pavel Shchors Date: Sat, 21 Mar 2026 20:03:09 +0100 Subject: [PATCH 5/5] Remove tests --- pytest.ini | 2 - tests/__init__.py | 1 - tests/conftest.py | 3 - tests/test_migration_resrobot.py | 213 ------------------------------- 4 files changed, 219 deletions(-) delete mode 100644 pytest.ini delete mode 100644 tests/__init__.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_migration_resrobot.py diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index f340014..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -asyncio_mode = auto diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index faba5e1..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the HASL integration.""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 278cc87..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Pytest configuration for HASL integration tests.""" - -pytest_plugins = "pytest_homeassistant_custom_component" diff --git a/tests/test_migration_resrobot.py b/tests/test_migration_resrobot.py deleted file mode 100644 index fc2e77a..0000000 --- a/tests/test_migration_resrobot.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tests for Resrobot entry migration.""" - -import pytest -from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN -from homeassistant.helpers import entity_registry as er -from pytest_homeassistant_custom_component.common import MockConfigEntry - -from custom_components.hasl3 import async_migrate_integration -from custom_components.hasl3.const import ( - CONF_DESTINATION, - CONF_INTEGRATION_TYPE, - CONF_RR_KEY, - CONF_SOURCE, - DOMAIN, - SENSOR_RRARR, - SENSOR_RRDEP, - SENSOR_RRROUTE, - SENSOR_RESROBOT_ARRIVAL, - SENSOR_RESROBOT_DEPARTURE, - SENSOR_RESROBOT_ROUTE, - SENSOR_STATUS, - SERVICE_RESROBOT_KEY, -) - -NEW_VERSION = 5 - - -def _legacy_rr_entry( - hass, - *, - title: str, - entry_id: str, - rr_key: str, - integration_type: str, - data_extra: dict | None = None, - version: int = 4, -): - data = { - CONF_INTEGRATION_TYPE: integration_type, - CONF_RR_KEY: rr_key, - **(data_extra or {}), - } - entry = MockConfigEntry( - domain=DOMAIN, - title=title, - data=data, - entry_id=entry_id, - version=version, - ) - entry.add_to_hass(hass) - return entry - - -@pytest.mark.asyncio -async def test_migrate_resrobot_single_key_creates_parent_and_subentries(hass): - entry_dep = _legacy_rr_entry( - hass, - title="RR Departures", - entry_id="rrdep", - rr_key="key-1", - integration_type=SENSOR_RRDEP, - data_extra={CONF_SOURCE: "1001"}, - ) - _legacy_rr_entry( - hass, - title="RR Arrivals", - entry_id="rrarr", - rr_key="key-1", - integration_type=SENSOR_RRARR, - data_extra={CONF_DESTINATION: "2002"}, - ) - _legacy_rr_entry( - hass, - title="RR Route", - entry_id="rrroute", - rr_key="key-1", - integration_type=SENSOR_RRROUTE, - data_extra={CONF_SOURCE: "1001", CONF_DESTINATION: "2002"}, - ) - - await async_migrate_integration(hass) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - parent = entries[0] - assert parent.entry_id == entry_dep.entry_id - assert parent.data == { - CONF_RR_KEY: "key-1", - CONF_INTEGRATION_TYPE: SERVICE_RESROBOT_KEY, - } - assert parent.options == {} - assert parent.version == NEW_VERSION - - subentries = list(parent.subentries.values()) - assert len(subentries) == 3 - - subentry_types = {subentry.data[CONF_INTEGRATION_TYPE] for subentry in subentries} - assert subentry_types == { - SENSOR_RESROBOT_DEPARTURE, - SENSOR_RESROBOT_ARRIVAL, - SENSOR_RESROBOT_ROUTE, - } - - for subentry in subentries: - assert CONF_RR_KEY not in subentry.data - assert subentry.title in {"RR Departures", "RR Arrivals", "RR Route"} - - -@pytest.mark.asyncio -async def test_migrate_resrobot_multiple_keys_grouped(hass): - _legacy_rr_entry( - hass, - title="RR Departures A", - entry_id="rrdep_a", - rr_key="key-a", - integration_type=SENSOR_RRDEP, - data_extra={CONF_SOURCE: "1001"}, - ) - _legacy_rr_entry( - hass, - title="RR Arrivals A", - entry_id="rrarr_a", - rr_key="key-a", - integration_type=SENSOR_RRARR, - data_extra={CONF_DESTINATION: "2002"}, - ) - _legacy_rr_entry( - hass, - title="RR Departures B", - entry_id="rrdep_b", - rr_key="key-b", - integration_type=SENSOR_RRDEP, - data_extra={CONF_SOURCE: "3003"}, - ) - - await async_migrate_integration(hass) - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 2 - - by_key = {entry.data[CONF_RR_KEY]: entry for entry in entries} - assert set(by_key) == {"key-a", "key-b"} - - assert len(by_key["key-a"].subentries) == 2 - assert len(by_key["key-b"].subentries) == 1 - - -@pytest.mark.asyncio -async def test_migrate_resrobot_updates_entity_registry(hass): - entry_dep = _legacy_rr_entry( - hass, - title="RR Departures", - entry_id="rrdep", - rr_key="key-1", - integration_type=SENSOR_RRDEP, - data_extra={CONF_SOURCE: "1001"}, - ) - - entity_registry = er.async_get(hass) - entity = entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - entry_dep.entry_id, - suggested_object_id="rrdep", - ) - - await async_migrate_integration(hass) - - parent = hass.config_entries.async_entries(DOMAIN)[0] - subentry = next(iter(parent.subentries.values())) - updated = entity_registry.async_get(entity.entity_id) - - assert updated.config_entry_id == parent.entry_id - assert updated.config_subentry_id == subentry.subentry_id - assert updated.unique_id == subentry.subentry_id - - -@pytest.mark.asyncio -async def test_migrate_resrobot_idempotent_when_version_is_new(hass): - entry = _legacy_rr_entry( - hass, - title="RR Departures", - entry_id="rrdep", - rr_key="key-1", - integration_type=SENSOR_RRDEP, - data_extra={CONF_SOURCE: "1001"}, - version=NEW_VERSION, - ) - - await async_migrate_integration(hass) - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries == [entry] - assert entries[0].data[CONF_INTEGRATION_TYPE] == SENSOR_RRDEP - - -@pytest.mark.asyncio -async def test_migrate_non_resrobot_entries_untouched(hass): - entry = MockConfigEntry( - domain=DOMAIN, - title="Status", - data={CONF_INTEGRATION_TYPE: SENSOR_STATUS}, - entry_id="status", - version=4, - ) - entry.add_to_hass(hass) - - await async_migrate_integration(hass) - - entries = hass.config_entries.async_entries(DOMAIN) - assert entries == [entry] - assert entries[0].data[CONF_INTEGRATION_TYPE] == SENSOR_STATUS