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 <joostlek@outlook.com>
This commit is contained in:
Shai Ungar 2024-07-11 09:45:32 +03:00 committed by GitHub
parent ea5eb0f8f2
commit 56b6747bc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1209 additions and 0 deletions

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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."

View File

@ -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
]

View File

@ -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"
}
}
}
}

View File

@ -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"]
}

View File

@ -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]
)

View File

@ -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"
}
}
}
}

View File

@ -273,6 +273,7 @@ FLOWS = {
"ipp",
"iqvia",
"islamic_prayer_times",
"israel_rail",
"iss",
"ista_ecotrend",
"isy994",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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",
),
]

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_title_departure',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_departure',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_departure_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_departure_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_title_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'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': <UnitOfTime.SECONDS: 's'>,
})
# ---
# 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': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_title_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1200',
})
# ---
# name: test_valid_config[sensor.mock_title_platform-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_title_platform',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_platform',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_valid_config[sensor.mock_title_train_number-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_train_number',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1234',
})
# ---
# name: test_valid_config[sensor.mock_title_transfers-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_title_transfers',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.mock_title_transfers',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---

View File

@ -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"

View File

@ -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

View File

@ -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