From 26437bb2537a5cea200a30a00acd7f3f19a2af6d Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:17:34 +0100 Subject: [PATCH] Adds ConfigFlow for London Underground (#152050) Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- .../components/london_underground/__init__.py | 35 ++++ .../london_underground/config_flow.py | 152 ++++++++++++++ .../components/london_underground/const.py | 20 +- .../london_underground/coordinator.py | 12 +- .../london_underground/manifest.json | 5 +- .../components/london_underground/sensor.py | 79 ++++++-- .../london_underground/strings.json | 38 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 +- .../components/london_underground/conftest.py | 65 ++++++ .../london_underground/test_config_flow.py | 186 ++++++++++++++++++ .../london_underground/test_init.py | 20 ++ .../london_underground/test_sensor.py | 119 +++++++++-- 13 files changed, 703 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/london_underground/config_flow.py create mode 100644 homeassistant/components/london_underground/strings.json create mode 100644 tests/components/london_underground/conftest.py create mode 100644 tests/components/london_underground/test_config_flow.py create mode 100644 tests/components/london_underground/test_init.py diff --git a/homeassistant/components/london_underground/__init__.py b/homeassistant/components/london_underground/__init__.py index b38aba6dbc34..c9910ee84610 100644 --- a/homeassistant/components/london_underground/__init__.py +++ b/homeassistant/components/london_underground/__init__.py @@ -1 +1,36 @@ """The london_underground component.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN as DOMAIN +from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry, TubeData + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: LondonUndergroundConfigEntry +) -> bool: + """Set up London Underground from a config entry.""" + + session = async_get_clientsession(hass) + data = TubeData(session) + coordinator = LondonTubeCoordinator(hass, data, config_entry=entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + # Forward the setup to the sensor platform + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: LondonUndergroundConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/london_underground/config_flow.py b/homeassistant/components/london_underground/config_flow.py new file mode 100644 index 000000000000..baca9b91c32b --- /dev/null +++ b/homeassistant/components/london_underground/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for London Underground integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from london_tube_status import TubeData +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.core import callback +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_LINE, DEFAULT_LINES, DOMAIN, TUBE_LINES + +_LOGGER = logging.getLogger(__name__) + + +class LondonUndergroundConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for London Underground.""" + + VERSION = 1 + MINOR_VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + _: ConfigEntry, + ) -> LondonUndergroundOptionsFlow: + """Get the options flow for this handler.""" + return LondonUndergroundOptionsFlow() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + session = async_get_clientsession(self.hass) + data = TubeData(session) + try: + async with asyncio.timeout(10): + await data.update() + except TimeoutError: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="London Underground", + data={}, + options={CONF_LINE: user_input.get(CONF_LINE, DEFAULT_LINES)}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LINE, + default=DEFAULT_LINES, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=TUBE_LINES, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult: + """Handle import from configuration.yaml.""" + session = async_get_clientsession(self.hass) + data = TubeData(session) + try: + async with asyncio.timeout(10): + await data.update() + except Exception: + _LOGGER.exception( + "Unexpected error trying to connect before importing config, aborting import " + ) + return self.async_abort(reason="cannot_connect") + + _LOGGER.warning( + "Importing London Underground config from configuration.yaml: %s", + import_data, + ) + # Extract lines from the sensor platform config + lines = import_data.get(CONF_LINE, DEFAULT_LINES) + if "London Overground" in lines: + _LOGGER.warning( + "London Overground was removed from the configuration as the line has been divided and renamed" + ) + lines.remove("London Overground") + return self.async_create_entry( + title="London Underground", + data={}, + options={CONF_LINE: import_data.get(CONF_LINE, DEFAULT_LINES)}, + ) + + +class LondonUndergroundOptionsFlow(OptionsFlowWithReload): + """Handle options.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + _LOGGER.debug( + "Updating london underground with options flow user_input: %s", + user_input, + ) + return self.async_create_entry( + title="", + data={CONF_LINE: user_input[CONF_LINE]}, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LINE, + default=self.config_entry.options.get( + CONF_LINE, + self.config_entry.data.get(CONF_LINE, DEFAULT_LINES), + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=TUBE_LINES, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 447ed4461f3a..9c96ff1ece04 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -6,7 +6,6 @@ DOMAIN = "london_underground" CONF_LINE = "line" - SCAN_INTERVAL = timedelta(seconds=30) TUBE_LINES = [ @@ -18,7 +17,7 @@ TUBE_LINES = [ "Elizabeth line", "Hammersmith & City", "Jubilee", - "London Overground", + "London Overground", # no longer supported "Metropolitan", "Northern", "Piccadilly", @@ -31,3 +30,20 @@ TUBE_LINES = [ "Weaver", "Windrush", ] + +# Default lines to monitor if none selected +DEFAULT_LINES = [ + "Bakerloo", + "Central", + "Circle", + "District", + "DLR", + "Elizabeth line", + "Hammersmith & City", + "Jubilee", + "Metropolitan", + "Northern", + "Piccadilly", + "Victoria", + "Waterloo & City", +] diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py index 29d1e8e2f54a..a80150e6313d 100644 --- a/homeassistant/components/london_underground/coordinator.py +++ b/homeassistant/components/london_underground/coordinator.py @@ -8,6 +8,7 @@ from typing import cast from london_tube_status import TubeData +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -15,16 +16,23 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type LondonUndergroundConfigEntry = ConfigEntry[LondonTubeCoordinator] + class LondonTubeCoordinator(DataUpdateCoordinator[dict[str, dict[str, str]]]): """London Underground sensor coordinator.""" - def __init__(self, hass: HomeAssistant, data: TubeData) -> None: + def __init__( + self, + hass: HomeAssistant, + data: TubeData, + config_entry: LondonUndergroundConfigEntry, + ) -> None: """Initialize coordinator.""" super().__init__( hass, _LOGGER, - config_entry=None, + config_entry=config_entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index 94b993097c02..1b9b8ddcbeb8 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -2,9 +2,12 @@ "domain": "london_underground", "name": "London Underground", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/london_underground", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], "quality_scale": "legacy", - "requirements": ["london-tube-status==0.5"] + "requirements": ["london-tube-status==0.5"], + "single_config_entry": true } diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 645f8f48ae2e..c9df10b470c6 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -5,23 +5,26 @@ from __future__ import annotations import logging from typing import Any -from london_tube_status import TubeData import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_LINE, TUBE_LINES -from .coordinator import LondonTubeCoordinator +from .const import CONF_LINE, DOMAIN, TUBE_LINES +from .coordinator import LondonTubeCoordinator, LondonUndergroundConfigEntry _LOGGER = logging.getLogger(__name__) @@ -38,18 +41,54 @@ async def async_setup_platform( ) -> None: """Set up the Tube sensor.""" - session = async_get_clientsession(hass) + # If configuration.yaml config exists, trigger the import flow. + # If the config entry already exists, this will not be triggered as only one config is allowed. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "London Underground", + }, + ) + return - data = TubeData(session) - coordinator = LondonTubeCoordinator(hass, data) + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "London Underground", + }, + ) - await coordinator.async_refresh() - if not coordinator.last_update_success: - raise PlatformNotReady +async def async_setup_entry( + hass: HomeAssistant, + entry: LondonUndergroundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the London Underground sensor from config entry.""" async_add_entities( - LondonTubeSensor(coordinator, line) for line in config[CONF_LINE] + LondonTubeSensor(entry.runtime_data, line) for line in entry.options[CONF_LINE] ) @@ -58,11 +97,21 @@ class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): _attr_attribution = "Powered by TfL Open Data" _attr_icon = "mdi:subway" + _attr_has_entity_name = True # Use modern entity naming def __init__(self, coordinator: LondonTubeCoordinator, name: str) -> None: """Initialize the London Underground sensor.""" super().__init__(coordinator) self._name = name + # Add unique_id for proper entity registry + self._attr_unique_id = f"tube_{name.lower().replace(' ', '_')}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, DOMAIN)}, + name="London Underground", + manufacturer="Transport for London", + model="Tube Status", + entry_type=DeviceEntryType.SERVICE, + ) @property def name(self) -> str: diff --git a/homeassistant/components/london_underground/strings.json b/homeassistant/components/london_underground/strings.json new file mode 100644 index 000000000000..b924d281c04d --- /dev/null +++ b/homeassistant/components/london_underground/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up London Underground", + "description": "Select which tube lines you want to monitor", + "data": { + "line": "Tube lines" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure London Underground", + "description": "[%key:component::london_underground::config::step::user::description%]", + "data": { + "line": "[%key:component::london_underground::config::step::user::data::line%]" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "title": "London Underground YAML configuration deprecated", + "description": "Configuring London Underground using YAML sensor platform is deprecated.\n\nWhile importing your configuration, an error occurred when trying to connect to the Transport for London API. Please restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fad6bf969391..3127aa4e6618 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -367,6 +367,7 @@ FLOWS = { "local_ip", "local_todo", "locative", + "london_underground", "lookin", "loqed", "luftdaten", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cf28545b785..821a5892cfc1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3688,9 +3688,10 @@ }, "london_underground": { "name": "London Underground", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true }, "lookin": { "name": "LOOKin", diff --git a/tests/components/london_underground/conftest.py b/tests/components/london_underground/conftest.py new file mode 100644 index 000000000000..9999133d3e00 --- /dev/null +++ b/tests/components/london_underground/conftest.py @@ -0,0 +1,65 @@ +"""Fixtures for the london_underground tests.""" + +from collections.abc import AsyncGenerator +import json +from unittest.mock import AsyncMock, patch + +from london_tube_status import parse_api_response +import pytest + +from homeassistant.components.london_underground.const import CONF_LINE, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_load_fixture +from tests.conftest import AiohttpClientMocker + + +@pytest.fixture +def mock_setup_entry(): + """Prevent setup of integration during tests.""" + with patch( + "homeassistant.components.london_underground.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock the config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_LINE: ["Metropolitan"]}, + title="London Underground", + ) + # Add and set up the entry + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + return entry + + +@pytest.fixture +async def mock_london_underground_client( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> AsyncGenerator[AsyncMock]: + """Mock a London Underground client.""" + with ( + patch( + "homeassistant.components.london_underground.TubeData", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.london_underground.config_flow.TubeData", + new=mock_client, + ), + ): + client = mock_client.return_value + + # Load the fixture text + fixture_text = await async_load_fixture(hass, "line_status.json", DOMAIN) + fixture_data = parse_api_response(json.loads(fixture_text)) + client.data = fixture_data + + yield client diff --git a/tests/components/london_underground/test_config_flow.py b/tests/components/london_underground/test_config_flow.py new file mode 100644 index 000000000000..72324d51c8a8 --- /dev/null +++ b/tests/components/london_underground/test_config_flow.py @@ -0,0 +1,186 @@ +"""Test the London Underground config flow.""" + +import asyncio + +import pytest + +from homeassistant.components.london_underground.const import ( + CONF_LINE, + DEFAULT_LINES, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir + + +async def test_validate_input_success( + hass: HomeAssistant, mock_setup_entry, mock_london_underground_client +) -> None: + """Test successful validation of TfL API.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: ["Bakerloo", "Central"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == {CONF_LINE: ["Bakerloo", "Central"]} + + +async def test_options( + hass: HomeAssistant, mock_setup_entry, mock_config_entry +) -> None: + """Test updating options.""" + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LINE: ["Bakerloo", "Central"], + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_LINE: ["Bakerloo", "Central"], + } + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (Exception, "cannot_connect"), + (asyncio.TimeoutError, "timeout_connect"), + ], +) +async def test_validate_input_exceptions( + hass: HomeAssistant, + mock_setup_entry, + mock_london_underground_client, + side_effect, + expected_error, +) -> None: + """Test validation with connection and timeout errors.""" + + mock_london_underground_client.update.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LINE: ["Bakerloo", "Central"]}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + + # confirm recovery after error + mock_london_underground_client.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == {CONF_LINE: DEFAULT_LINES} + + +async def test_already_configured( + hass: HomeAssistant, + mock_london_underground_client, + mock_setup_entry, + mock_config_entry, +) -> None: + """Try (and fail) setting up a config entry when one already exists.""" + + # Try to start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "London Underground" + assert result["data"] == {} + assert result["options"] == { + CONF_LINE: ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"] + } + + +async def test_failed_yaml_import_connection( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + mock_london_underground_client.update.side_effect = asyncio.TimeoutError + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_failed_yaml_import_already_configured( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, + mock_config_entry, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + + IMPORT_DATA = { + "platform": "london_underground", + "line": ["Central", "Piccadilly", "Victoria", "Bakerloo", "Northern"], + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=IMPORT_DATA + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/london_underground/test_init.py b/tests/components/london_underground/test_init.py new file mode 100644 index 000000000000..fd3237564e2d --- /dev/null +++ b/tests/components/london_underground/test_init.py @@ -0,0 +1,20 @@ +"""Test the London Underground init.""" + +from homeassistant.core import HomeAssistant + + +async def test_reload_entry( + hass: HomeAssistant, mock_london_underground_client, mock_config_entry +) -> None: + """Test reloading the config entry.""" + + # Test reloading with updated options + hass.config_entries.async_update_entry( + mock_config_entry, + data={}, + options={"line": ["Bakerloo", "Central"]}, + ) + await hass.async_block_till_done() + + # Verify that setup was called for each reload + assert len(mock_london_underground_client.mock_calls) > 0 diff --git a/tests/components/london_underground/test_sensor.py b/tests/components/london_underground/test_sensor.py index ccb64401eb5e..2a31ecad2cf7 100644 --- a/tests/components/london_underground/test_sensor.py +++ b/tests/components/london_underground/test_sensor.py @@ -1,37 +1,130 @@ """The tests for the london_underground platform.""" -from london_tube_status import API_URL +import asyncio + +import pytest from homeassistant.components.london_underground.const import CONF_LINE, DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from tests.common import async_load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - VALID_CONFIG = { "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} } async def test_valid_state( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + mock_config_entry, ) -> None: - """Test for operational london_underground sensor with proper attributes.""" - aioclient_mock.get( - API_URL, - text=await async_load_fixture(hass, "line_status.json", DOMAIN), - ) + """Test operational London Underground sensor using a mock config entry.""" + # Ensure the entry is fully loaded + assert mock_config_entry.state is ConfigEntryState.LOADED + # Confirm that the expected entity exists and is correct + state = hass.states.get("sensor.london_underground_metropolitan") + assert state is not None + assert state.state == "Good Service" + assert state.attributes == { + "Description": "Nothing to report", + "attribution": "Powered by TfL Open Data", + "friendly_name": "London Underground Metropolitan", + "icon": "mdi:subway", + } + + # No YAML warning should be issued, since setup was not via YAML + assert not issue_registry.async_get_issue(DOMAIN, "yaml_deprecated") + + +async def test_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + VALID_CONFIG = { + "sensor": { + "platform": "london_underground", + CONF_LINE: ["Metropolitan", "London Overground"], + } + } assert await async_setup_component(hass, "sensor", VALID_CONFIG) await hass.async_block_till_done() - state = hass.states.get("sensor.metropolitan") + # Verify the config entry was created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify a warning was issued about YAML deprecation + assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_yaml") + + # Check the state after setup completes + state = hass.states.get("sensor.london_underground_metropolitan") assert state assert state.state == "Good Service" assert state.attributes == { "Description": "Nothing to report", "attribution": "Powered by TfL Open Data", - "friendly_name": "Metropolitan", + "friendly_name": "London Underground Metropolitan", "icon": "mdi:subway", } + + # Since being renamed London overground is no longer returned by the API + # So check that we do not import it and that we warn the user + state = hass.states.get("sensor.london_underground_london_overground") + assert not state + assert any( + "London Overground was removed from the configuration as the line has been divided and renamed" + in record.message + for record in caplog.records + ) + + +async def test_failed_yaml_import( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_london_underground_client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a YAML sensor is imported and becomes an operational config entry.""" + # Set up via YAML which will trigger import and set up the config entry + mock_london_underground_client.update.side_effect = asyncio.TimeoutError + VALID_CONFIG = { + "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} + } + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + + # Verify the config entry was not created + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # verify no flows still in progress + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + assert any( + "Unexpected error trying to connect before importing config" in record.message + for record in caplog.records + ) + # Confirm that the import did not happen + assert not any( + "Importing London Underground config from configuration.yaml" in record.message + for record in caplog.records + ) + + assert not any( + "migrated to a config entry and can be safely removed" in record.message + for record in caplog.records + ) + + # Verify a warning was issued about YAML not being imported + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_cannot_connect" + )