From 56b6747bc0fb604e069e22b9949abc4285e62852 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Thu, 11 Jul 2024 09:45:32 +0300 Subject: [PATCH] Add Israel rail integration (#121418) * Add Israel Rail integration * israel_rail tests * israel_rail tests * 1. use entry.runtime 2. DataConnection - data class 3. remove unique id from coordinator 4. use EntityDescription * add a list of stations in user form * 1. extend ConfigEntry 2. remove unused pop 3. use IsraelRailSensorEntityDescription to have only one kind of Sensor 4. add test for already configured 5. use snapshot in test * change user step description * 1. ConfigEntry[IsraelRailDataUpdateCoordinator] 2. remove redundant attributes 3. use snapshot_platform helper * remove attr * remove attr * move test to test_init.py * Fix * Fix * Fix * Fix * fix timezone * fix * fix --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/israel_rail/__init__.py | 58 +++ .../components/israel_rail/config_flow.py | 61 ++++ homeassistant/components/israel_rail/const.py | 17 + .../components/israel_rail/coordinator.py | 113 ++++++ .../components/israel_rail/icons.json | 27 ++ .../components/israel_rail/manifest.json | 10 + .../components/israel_rail/sensor.py | 125 +++++++ .../components/israel_rail/strings.json | 42 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/israel_rail/__init__.py | 28 ++ tests/components/israel_rail/conftest.py | 185 ++++++++++ .../israel_rail/snapshots/test_sensor.ambr | 335 ++++++++++++++++++ .../israel_rail/test_config_flow.py | 87 +++++ tests/components/israel_rail/test_init.py | 22 ++ tests/components/israel_rail/test_sensor.py | 84 +++++ 19 files changed, 1209 insertions(+) create mode 100644 homeassistant/components/israel_rail/__init__.py create mode 100644 homeassistant/components/israel_rail/config_flow.py create mode 100644 homeassistant/components/israel_rail/const.py create mode 100644 homeassistant/components/israel_rail/coordinator.py create mode 100644 homeassistant/components/israel_rail/icons.json create mode 100644 homeassistant/components/israel_rail/manifest.json create mode 100644 homeassistant/components/israel_rail/sensor.py create mode 100644 homeassistant/components/israel_rail/strings.json create mode 100644 tests/components/israel_rail/__init__.py create mode 100644 tests/components/israel_rail/conftest.py create mode 100644 tests/components/israel_rail/snapshots/test_sensor.ambr create mode 100644 tests/components/israel_rail/test_config_flow.py create mode 100644 tests/components/israel_rail/test_init.py create mode 100644 tests/components/israel_rail/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index ac49e36d9ec..3a21c248a46 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -707,6 +707,8 @@ build.json @home-assistant/supervisor /tests/components/isal/ @bdraco /homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair /tests/components/islamic_prayer_times/ @engrbm87 @cpfair +/homeassistant/components/israel_rail/ @shaiu +/tests/components/israel_rail/ @shaiu /homeassistant/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol /homeassistant/components/ista_ecotrend/ @tr4nt0r diff --git a/homeassistant/components/israel_rail/__init__.py b/homeassistant/components/israel_rail/__init__.py new file mode 100644 index 00000000000..3c33a159a63 --- /dev/null +++ b/homeassistant/components/israel_rail/__init__.py @@ -0,0 +1,58 @@ +"""The Israel Rail component.""" + +import logging + +from israelrailapi import TrainSchedule + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_DESTINATION, CONF_START, DOMAIN +from .coordinator import IsraelRailDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +type IsraelRailConfigEntry = ConfigEntry[IsraelRailDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -> bool: + """Set up Israel rail from a config entry.""" + config = entry.data + + start = config[CONF_START] + destination = config[CONF_DESTINATION] + + train_schedule = TrainSchedule() + + try: + await hass.async_add_executor_job(train_schedule.query, start, destination) + except Exception as e: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="request_timeout", + translation_placeholders={ + "config_title": entry.title, + "error": str(e), + }, + ) from e + + israel_rail_coordinator = IsraelRailDataUpdateCoordinator( + hass, train_schedule, start, destination + ) + await israel_rail_coordinator.async_config_entry_first_refresh() + entry.runtime_data = israel_rail_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/israel_rail/config_flow.py b/homeassistant/components/israel_rail/config_flow.py new file mode 100644 index 00000000000..3adecaf428c --- /dev/null +++ b/homeassistant/components/israel_rail/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for israel rail.""" + +import logging +from typing import Any + +from israelrailapi import TrainSchedule +from israelrailapi.stations import STATIONS +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_DESTINATION, CONF_START, DOMAIN + +STATIONS_NAMES = [station["Heb"] for station in STATIONS.values()] + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_START): vol.In(STATIONS_NAMES), + vol.Required(CONF_DESTINATION): vol.In(STATIONS_NAMES), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class IsraelRailConfigFlow(ConfigFlow, domain=DOMAIN): + """Israel rail config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Async user step to set up the connection.""" + errors = {} + if user_input: + train_schedule = TrainSchedule() + try: + await self.hass.async_add_executor_job( + train_schedule.query, + user_input[CONF_START], + user_input[CONF_DESTINATION], + ) + except Exception: + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + if not errors: + unique_id = f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/israel_rail/const.py b/homeassistant/components/israel_rail/const.py new file mode 100644 index 00000000000..bb9c7534638 --- /dev/null +++ b/homeassistant/components/israel_rail/const.py @@ -0,0 +1,17 @@ +"""Constants for the israel rail integration.""" + +from datetime import timedelta +from typing import Final + +DOMAIN = "israel_rail" + +CONF_START: Final = "from" +CONF_DESTINATION: Final = "to" + +DEFAULT_NAME = "Next Destination" + +DEPARTURES_COUNT = 3 + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=90) + +ATTRIBUTION = "Data provided by Israel rail." diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py new file mode 100644 index 00000000000..952a3923119 --- /dev/null +++ b/homeassistant/components/israel_rail/coordinator.py @@ -0,0 +1,113 @@ +"""DataUpdateCoordinator for the israel rail integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from israelrailapi import TrainSchedule +from israelrailapi.api import TrainRoute +from israelrailapi.train_station import station_name_to_id + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DEFAULT_SCAN_INTERVAL, DEPARTURES_COUNT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DataConnection: + """A connection data class.""" + + departure: datetime | None + duration: int | None + platform: str + remaining_time: str + start: str + destination: str + train_number: str + transfers: int + + +def calculate_duration_in_seconds(start_time: str, end_time: str) -> int | None: + """Transform and calculate the duration from start and end time into seconds.""" + end_time_date = dt_util.parse_datetime(end_time) + start_time_date = dt_util.parse_datetime(start_time) + if not end_time_date or not start_time_date: + return None + return (end_time_date - start_time_date).seconds + + +def departure_time(train_route: TrainRoute) -> datetime | None: + """Get departure time.""" + start_datetime = dt_util.parse_datetime(train_route.start_time) + return start_datetime.astimezone() if start_datetime else None + + +def remaining_time(departure) -> timedelta | None: + """Calculate the remaining time for the departure.""" + departure_datetime = dt_util.parse_datetime(departure) + + if departure_datetime: + return dt_util.as_local(departure_datetime) - dt_util.as_local(dt_util.utcnow()) + return None + + +class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection]]): + """A IsraelRail Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + train_schedule: TrainSchedule, + start: str, + destination: str, + ) -> None: + """Initialize the IsraelRail data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._train_schedule = train_schedule + self._start = start + self._destination = destination + + async def _async_update_data(self) -> list[DataConnection]: + try: + train_routes = await self.hass.async_add_executor_job( + self._train_schedule.query, + self._start, + self._destination, + datetime.now().strftime("%Y-%m-%d"), + datetime.now().strftime("%H:%M"), + ) + except Exception as e: + raise UpdateFailed( + "Unable to connect and retrieve data from israelrail api", + ) from e + + return [ + DataConnection( + departure=departure_time(train_routes[i]), + train_number=train_routes[i].trains[0].data["trainNumber"], + platform=train_routes[i].trains[0].platform, + transfers=len(train_routes[i].trains) - 1, + duration=calculate_duration_in_seconds( + train_routes[i].start_time, train_routes[i].end_time + ), + start=station_name_to_id(train_routes[i].trains[0].src), + destination=station_name_to_id(train_routes[i].trains[-1].dst), + remaining_time=str(remaining_time(train_routes[i].trains[0].departure)), + ) + for i in range(DEPARTURES_COUNT) + if len(train_routes) > i and train_routes[i] is not None + ] diff --git a/homeassistant/components/israel_rail/icons.json b/homeassistant/components/israel_rail/icons.json new file mode 100644 index 00000000000..c14e8804b98 --- /dev/null +++ b/homeassistant/components/israel_rail/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "departure0": { + "default": "mdi:bus-clock" + }, + "departure1": { + "default": "mdi:bus-clock" + }, + "departure2": { + "default": "mdi:bus-clock" + }, + "duration": { + "default": "mdi:timeline-clock" + }, + "transfers": { + "default": "mdi:transit-transfer" + }, + "platform": { + "default": "mdi:bus-stop-uncovered" + }, + "train_number": { + "default": "mdi:train" + } + } + } +} diff --git a/homeassistant/components/israel_rail/manifest.json b/homeassistant/components/israel_rail/manifest.json new file mode 100644 index 00000000000..afe085f5729 --- /dev/null +++ b/homeassistant/components/israel_rail/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "israel_rail", + "name": "Israel Railways", + "codeowners": ["@shaiu"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/israel_rail", + "iot_class": "cloud_polling", + "loggers": ["israelrailapi"], + "requirements": ["israel-rail-api==0.1.2"] +} diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py new file mode 100644 index 00000000000..1f6f20f82b2 --- /dev/null +++ b/homeassistant/components/israel_rail/sensor.py @@ -0,0 +1,125 @@ +"""Support for israel rail.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +import logging +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import IsraelRailConfigEntry +from .const import ATTRIBUTION, DEPARTURES_COUNT, DOMAIN +from .coordinator import DataConnection, IsraelRailDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(kw_only=True, frozen=True) +class IsraelRailSensorEntityDescription(SensorEntityDescription): + """Describes israel rail sensor entity.""" + + value_fn: Callable[[DataConnection], StateType | datetime] + + index: int = 0 + + +DEPARTURE_SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( + *[ + IsraelRailSensorEntityDescription( + key=f"departure{i or ''}", + translation_key=f"departure{i}", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data_connection: data_connection.departure, + index=i, + ) + for i in range(DEPARTURES_COUNT) + ], +) + +SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( + IsraelRailSensorEntityDescription( + key="duration", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=lambda data_connection: data_connection.duration, + ), + IsraelRailSensorEntityDescription( + key="platform", + translation_key="platform", + value_fn=lambda data_connection: data_connection.platform, + ), + IsraelRailSensorEntityDescription( + key="transfers", + translation_key="transfers", + value_fn=lambda data_connection: data_connection.transfers, + ), + IsraelRailSensorEntityDescription( + key="train_number", + translation_key="train_number", + value_fn=lambda data_connection: data_connection.train_number, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IsraelRailConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + coordinator = config_entry.runtime_data + + unique_id = config_entry.unique_id + + if TYPE_CHECKING: + assert unique_id + + async_add_entities( + IsraelRailEntitySensor(coordinator, description, unique_id) + for description in (*DEPARTURE_SENSORS, *SENSORS) + ) + + +class IsraelRailEntitySensor( + CoordinatorEntity[IsraelRailDataUpdateCoordinator], SensorEntity +): + """Define a Israel Rail sensor.""" + + entity_description: IsraelRailSensorEntityDescription + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, + coordinator: IsraelRailDataUpdateCoordinator, + entity_description: IsraelRailSensorEntityDescription, + unique_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.data[self.entity_description.index] + ) diff --git a/homeassistant/components/israel_rail/strings.json b/homeassistant/components/israel_rail/strings.json new file mode 100644 index 00000000000..48a7058de4a --- /dev/null +++ b/homeassistant/components/israel_rail/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "step": { + "user": { + "data": { + "from": "Start station", + "to": "End station" + }, + "description": "Provide start and end station for your connection from the provided list", + "title": "Israel Rail" + } + } + }, + "entity": { + "sensor": { + "departure0": { + "name": "Departure" + }, + "departure1": { + "name": "Departure +1" + }, + "departure2": { + "name": "Departure +2" + }, + "transfers": { + "name": "Transfers" + }, + "platform": { + "name": "Platform" + }, + "train_number": { + "name": "Train number" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index faf84d9fd38..0a1b5e96516 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -273,6 +273,7 @@ FLOWS = { "ipp", "iqvia", "islamic_prayer_times", + "israel_rail", "iss", "ista_ecotrend", "isy994", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b798a02f7a8..90895c45cbd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2909,6 +2909,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "israel_rail": { + "name": "Israel Railways", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "iss": { "name": "International Space Station (ISS)", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index c44d0306030..d94203faa4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1184,6 +1184,9 @@ isal==1.6.1 # homeassistant.components.gogogate2 ismartgate==5.0.1 +# homeassistant.components.israel_rail +israel-rail-api==0.1.2 + # homeassistant.components.abode jaraco.abode==5.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e773c4ee86..2de9d364e39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -971,6 +971,9 @@ isal==1.6.1 # homeassistant.components.gogogate2 ismartgate==5.0.1 +# homeassistant.components.israel_rail +israel-rail-api==0.1.2 + # homeassistant.components.abode jaraco.abode==5.2.1 diff --git a/tests/components/israel_rail/__init__.py b/tests/components/israel_rail/__init__.py new file mode 100644 index 00000000000..23cf9f5a821 --- /dev/null +++ b/tests/components/israel_rail/__init__.py @@ -0,0 +1,28 @@ +"""Tests for the israel_rail component.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.israel_rail.const import DEFAULT_SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def init_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Set up the israel rail integration in Home Assistant.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +async def goto_future(hass: HomeAssistant, freezer: FrozenDateTimeFactory): + """Move to future.""" + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() diff --git a/tests/components/israel_rail/conftest.py b/tests/components/israel_rail/conftest.py new file mode 100644 index 00000000000..78abb0ee2f8 --- /dev/null +++ b/tests/components/israel_rail/conftest.py @@ -0,0 +1,185 @@ +"""Configuration for Israel rail tests.""" + +from datetime import datetime +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo + +from israelrailapi.api import TrainRoute +import pytest +from typing_extensions import Generator + +from homeassistant.components.israel_rail import CONF_DESTINATION, CONF_START, DOMAIN + +from tests.common import MockConfigEntry + +VALID_CONFIG = { + CONF_START: "באר יעקב", + CONF_DESTINATION: "אשקלון", +} + +SOURCE_DEST = "באר יעקב אשקלון" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.israel_rail.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + unique_id=SOURCE_DEST, + ) + + +@pytest.fixture +def mock_israelrail() -> AsyncMock: + """Build a fixture for the Israel rail API.""" + with ( + patch( + "homeassistant.components.israel_rail.TrainSchedule", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.israel_rail.config_flow.TrainSchedule", + new=mock_client, + ), + ): + client = mock_client.return_value + client.query.return_value = TRAINS + + yield client + + +def get_time(hour: int, minute: int) -> str: + """Return a time in isoformat.""" + return datetime(2021, 10, 10, hour, minute, 10, tzinfo=ZoneInfo("UTC")).isoformat() + + +def get_train_route( + train_number: str = "1234", + departure_time: str = "2021-10-10T10:10:10", + arrival_time: str = "2021-10-10T10:10:10", + origin_platform: str = "1", + dest_platform: str = "2", + origin_station: str = "3500", + destination_station: str = "3700", +) -> TrainRoute: + """Build a TrainRoute of the israelrail API.""" + return TrainRoute( + [ + { + "orignStation": origin_station, + "destinationStation": destination_station, + "departureTime": departure_time, + "arrivalTime": arrival_time, + "originPlatform": origin_platform, + "destPlatform": dest_platform, + "trainNumber": train_number, + } + ] + ) + + +TRAINS = [ + get_train_route( + train_number="1234", + departure_time=get_time(10, 10), + arrival_time=get_time(10, 30), + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1235", + departure_time=get_time(10, 20), + arrival_time=get_time(10, 40), + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1236", + departure_time=get_time(10, 30), + arrival_time=get_time(10, 50), + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1237", + departure_time=get_time(10, 40), + arrival_time=get_time(11, 00), + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1238", + departure_time=get_time(10, 50), + arrival_time=get_time(11, 10), + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), +] + +TRAINS_WRONG_FORMAT = [ + get_train_route( + train_number="1234", + departure_time="2021-10-1010:10:10", + arrival_time="2021-10-10T10:30:10", + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1235", + departure_time="2021-10-1010:20:10", + arrival_time="2021-10-10T10:40:10", + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1236", + departure_time="2021-10-1010:30:10", + arrival_time="2021-10-10T10:50:10", + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1237", + departure_time="2021-10-1010:40:10", + arrival_time="2021-10-10T11:00:10", + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), + get_train_route( + train_number="1238", + departure_time="2021-10-1010:50:10", + arrival_time="2021-10-10T11:10:10", + origin_platform="1", + dest_platform="2", + origin_station="3500", + destination_station="3700", + ), +] diff --git a/tests/components/israel_rail/snapshots/test_sensor.ambr b/tests/components/israel_rail/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8ad66cd970b --- /dev/null +++ b/tests/components/israel_rail/snapshots/test_sensor.ambr @@ -0,0 +1,335 @@ +# serializer version: 1 +# name: test_valid_config[sensor.mock_title_departure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_departure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure0', + 'unique_id': 'באר יעקב אשקלון_departure', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_departure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Departure', + }), + 'context': , + 'entity_id': 'sensor.mock_title_departure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-10-10T10:10:10+00:00', + }) +# --- +# name: test_valid_config[sensor.mock_title_departure_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_departure_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure +1', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure1', + 'unique_id': 'באר יעקב אשקלון_departure1', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_departure_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Departure +1', + }), + 'context': , + 'entity_id': 'sensor.mock_title_departure_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-10-10T10:20:10+00:00', + }) +# --- +# name: test_valid_config[sensor.mock_title_departure_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_departure_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Departure +2', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'departure2', + 'unique_id': 'באר יעקב אשקלון_departure2', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_departure_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Departure +2', + }), + 'context': , + 'entity_id': 'sensor.mock_title_departure_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-10-10T10:30:10+00:00', + }) +# --- +# name: test_valid_config[sensor.mock_title_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Duration', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'באר יעקב אשקלון_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_valid_config[sensor.mock_title_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'device_class': 'duration', + 'friendly_name': 'Mock Title Duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1200', + }) +# --- +# name: test_valid_config[sensor.mock_title_platform-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_platform', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Platform', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'platform', + 'unique_id': 'באר יעקב אשקלון_platform', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_platform-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'friendly_name': 'Mock Title Platform', + }), + 'context': , + 'entity_id': 'sensor.mock_title_platform', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_valid_config[sensor.mock_title_train_number-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_train_number', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Train number', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'train_number', + 'unique_id': 'באר יעקב אשקלון_train_number', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_train_number-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'friendly_name': 'Mock Title Train number', + }), + 'context': , + 'entity_id': 'sensor.mock_title_train_number', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234', + }) +# --- +# name: test_valid_config[sensor.mock_title_transfers-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_transfers', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Transfers', + 'platform': 'israel_rail', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'transfers', + 'unique_id': 'באר יעקב אשקלון_transfers', + 'unit_of_measurement': None, + }) +# --- +# name: test_valid_config[sensor.mock_title_transfers-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Israel rail.', + 'friendly_name': 'Mock Title Transfers', + }), + 'context': , + 'entity_id': 'sensor.mock_title_transfers', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/israel_rail/test_config_flow.py b/tests/components/israel_rail/test_config_flow.py new file mode 100644 index 00000000000..a27d9b3420b --- /dev/null +++ b/tests/components/israel_rail/test_config_flow.py @@ -0,0 +1,87 @@ +"""Define tests for the israel rail config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.israel_rail import CONF_DESTINATION, CONF_START, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import VALID_CONFIG + +from tests.common import MockConfigEntry + + +async def test_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_israelrail: AsyncMock +) -> None: + """Test that the user step works.""" + 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"], + VALID_CONFIG, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "באר יעקב אשקלון" + assert result["data"] == { + CONF_START: "באר יעקב", + CONF_DESTINATION: "אשקלון", + } + + +async def test_flow_fails( + hass: HomeAssistant, + mock_israelrail: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the user step fails.""" + mock_israelrail.query.side_effect = Exception("error") + failed_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert failed_result["errors"] == {"base": "unknown"} + assert failed_result["type"] is FlowResultType.FORM + + mock_israelrail.query.side_effect = None + + result = await hass.config_entries.flow.async_configure( + failed_result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "באר יעקב אשקלון" + assert result["data"] == { + CONF_START: "באר יעקב", + CONF_DESTINATION: "אשקלון", + } + + +async def test_flow_already_configured( + hass: HomeAssistant, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the user step fails when the entry is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result_aborted = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result_aborted["type"] is FlowResultType.ABORT + assert result_aborted["reason"] == "already_configured" diff --git a/tests/components/israel_rail/test_init.py b/tests/components/israel_rail/test_init.py new file mode 100644 index 00000000000..c4dd4e5721e --- /dev/null +++ b/tests/components/israel_rail/test_init.py @@ -0,0 +1,22 @@ +"""Test init of israel_rail integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_invalid_config( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_israelrail: AsyncMock, +) -> None: + """Ensure nothing is created when config is wrong.""" + mock_israelrail.query.side_effect = Exception("error") + await init_integration(hass, mock_config_entry) + assert not hass.states.async_entity_ids("sensor") + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/israel_rail/test_sensor.py b/tests/components/israel_rail/test_sensor.py new file mode 100644 index 00000000000..8f338a80a86 --- /dev/null +++ b/tests/components/israel_rail/test_sensor.py @@ -0,0 +1,84 @@ +"""Tests for the israel_rail sensor.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import goto_future, init_integration +from .conftest import TRAINS, TRAINS_WRONG_FORMAT, get_time + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_valid_config( + hass: HomeAssistant, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Ensure everything starts correctly.""" + await init_integration(hass, mock_config_entry) + assert len(hass.states.async_entity_ids()) == 7 + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_update_train( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure the train data is updated.""" + await init_integration(hass, mock_config_entry) + assert len(hass.states.async_entity_ids()) == 7 + departure_sensor = hass.states.get("sensor.mock_title_departure") + expected_time = get_time(10, 10) + assert departure_sensor.state == expected_time + + mock_israelrail.query.return_value = TRAINS[1:] + + await goto_future(hass, freezer) + + assert len(hass.states.async_entity_ids()) == 7 + departure_sensor = hass.states.get("sensor.mock_title_departure") + expected_time = get_time(10, 20) + assert departure_sensor.state == expected_time + + +async def test_no_duration_wrong_date_format( + hass: HomeAssistant, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure the duration is not set when there is no departure time.""" + mock_israelrail.query.return_value = TRAINS_WRONG_FORMAT + await init_integration(hass, mock_config_entry) + assert len(hass.states.async_entity_ids()) == 7 + departure_sensor = hass.states.get("sensor.mock_title_train_number") + assert departure_sensor.state == "1234" + duration_sensor = hass.states.get("sensor.mock_title_duration") + assert duration_sensor.state == "unknown" + + +async def test_fail_query( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_israelrail: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure the integration handles query failures.""" + await init_integration(hass, mock_config_entry) + assert len(hass.states.async_entity_ids()) == 7 + mock_israelrail.query.side_effect = Exception("error") + await goto_future(hass, freezer) + assert len(hass.states.async_entity_ids()) == 7 + departure_sensor = hass.states.get("sensor.mock_title_departure") + assert departure_sensor.state == STATE_UNAVAILABLE