278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -1,18 +1,27 @@
from __future__ import annotations
import logging
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from homeassistant.helpers.selector import SelectSelector
from homeassistant.helpers.update_coordinator import UpdateFailed
from custom_components.seatemperatures import async_migrate_entry
from custom_components.seatemperatures import _async_fetch, async_migrate_entry
from custom_components.seatemperatures.api import (
MAP_LOCATIONS_TTL,
REQUEST_TIMEOUT,
SeaTemperatureAPI,
SeaTemperatureError,
parse_map_locations,
parse_search_results,
)
from custom_components.seatemperatures.config_flow import SeaTemperatureConfigFlow
from custom_components.seatemperatures.config_flow import (
CONTINENT_NAMES,
SeaTemperatureConfigFlow,
)
from custom_components.seatemperatures.const import (
CONF_AREA,
CONF_CONTINENT,
@@ -118,34 +127,6 @@ async def test_validate_location_path() -> None:
validate_location_path("/../../etc/passwd")
async def test_parse_search_results() -> None:
"""Search results should be normalized into path-based location records."""
data = {
"results": [
{
"name": "Copenhagen",
"country": "Denmark",
"region": "Europe",
"path": "/europe/denmark/copenhagen/",
"area": "Denmark, Europe",
},
{"name": "Broken", "path": "https://example.com/not-allowed"},
]
}
results = parse_search_results(data)
assert results == [
{
"name": "Copenhagen",
"country": "Denmark",
"region": "Europe",
"area": "Denmark, Europe",
"path": "/europe/denmark/copenhagen/",
}
]
async def test_parse_map_locations() -> None:
"""Legacy map-locations payload should support place_id migration."""
data = {
@@ -264,7 +245,7 @@ async def test_config_flow_stores_path_based_location(mock_hass) -> None:
}
}
with patch.object(SeaTemperatureAPI, "_get_map_locations", AsyncMock(return_value=locations_cache)):
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)):
user_result = await flow.async_step_user(None)
assert user_result["type"] == "form"
assert user_result["step_id"] == "user"
@@ -313,17 +294,26 @@ async def test_async_migrate_entry_from_place_id(mock_hass) -> None:
"path": "/africa/algeria/ain-el-turk/",
}
),
):
), patch(
# The unique_id migration is exercised in test_entry.py; here it would
# only drag a real entity registry into a mocked hass.
"custom_components.seatemperatures._async_migrate_unique_ids",
AsyncMock(),
) as migrate_unique_ids:
migrated = await async_migrate_entry(mock_hass, entry)
assert migrated is True
mock_hass.config_entries.async_update_entry.assert_called_once()
_, kwargs = mock_hass.config_entries.async_update_entry.call_args
migrate_unique_ids.assert_awaited_once()
assert mock_hass.config_entries.async_update_entry.call_count == 2
_, kwargs = mock_hass.config_entries.async_update_entry.call_args_list[0]
assert kwargs["data"][CONF_PLACE] == "Ain El Turk"
assert kwargs["data"][CONF_COUNTRY] == "Algeria"
assert kwargs["data"][CONF_PATH] == "/africa/algeria/ain-el-turk/"
assert kwargs["unique_id"] == "/africa/algeria/ain-el-turk/"
assert kwargs["version"] == 2
assert mock_hass.config_entries.async_update_entry.call_args_list[1].kwargs == {
"version": 4
}
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
@@ -335,3 +325,254 @@ async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_has
assert migrated is False
mock_hass.config_entries.async_update_entry.assert_not_called()
async def test_config_flow_reshows_the_form_when_the_site_is_unreachable(mock_hass) -> None:
"""An unreachable site must show a translated error, not an abort."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
with patch.object(SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=None)):
result = await flow.async_step_user(None)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_config_flow_retries_after_a_failed_fetch(mock_hass) -> None:
"""Submitting the error form should fetch again instead of raising."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
"sea-1": {
"name": "Copenhagen",
"country": "Denmark",
"area": "",
"path": "/europe/denmark/copenhagen/",
}
}
with patch.object(
SeaTemperatureAPI,
"get_map_locations",
AsyncMock(side_effect=[None, locations_cache]),
):
await flow.async_step_user(None)
# The retry form has no continent field, so it submits an empty mapping.
result = await flow.async_step_user({})
assert result["type"] == "form"
assert result["step_id"] == "user"
assert not result.get("errors")
assert flow._continents == ["Europe"]
# The nine first path segments /api/map-locations.json actually publishes.
PUBLISHED_CONTINENT_SLUGS = {
"africa",
"antarctica",
"asia",
"australia-and-oceania",
"central-america-and-the-caribbean",
"europe",
"middle-east",
"north-america",
"south-america",
}
async def test_continent_map_matches_the_published_slugs() -> None:
"""A stale slug adds a continent no location ever resolves to."""
assert set(CONTINENT_NAMES) == PUBLISHED_CONTINENT_SLUGS
async def test_continent_names_read_as_names() -> None:
"""Every published slug should map to one readable spelling."""
flow = SeaTemperatureConfigFlow()
names = {flow._get_continent_name(slug) for slug in PUBLISHED_CONTINENT_SLUGS}
assert names == {
"Africa",
"Antarctica",
"Asia",
"Australia and Oceania",
"Central America and the Caribbean",
"Europe",
"Middle East",
"North America",
"South America",
}
async def test_unknown_continent_slug_keeps_joining_words_small() -> None:
"""A slug the site adds later still has to read as a name."""
flow = SeaTemperatureConfigFlow()
assert flow._get_continent_name("islands-of-the-atlantic") == "Islands of the Atlantic"
def _selector_for(schema, key: str) -> SelectSelector:
"""Pull the selector a schema uses for one key."""
for marker, validator in schema.schema.items():
if str(marker) == key:
return validator
raise AssertionError(f"{key} is not in the schema")
async def test_every_step_offers_a_searchable_dropdown(mock_hass) -> None:
"""4,477 places in a plain list cannot be picked from without a search."""
flow = SeaTemperatureConfigFlow()
flow.hass = mock_hass
locations_cache = {
f"sea-{index}": {
"name": f"Place {index}",
"country": "United States",
"area": "",
"path": f"/north-america/united-states/place-{index}/",
}
for index in range(3)
}
with patch.object(
SeaTemperatureAPI, "get_map_locations", AsyncMock(return_value=locations_cache)
):
user_result = await flow.async_step_user(None)
await flow.async_step_user({CONF_CONTINENT: "North America"})
country_result = await flow.async_step_country(None)
await flow.async_step_country({CONF_COUNTRY: "United States"})
place_result = await flow.async_step_place(None)
for result, key in (
(user_result, CONF_CONTINENT),
(country_result, CONF_COUNTRY),
(place_result, CONF_PLACE),
):
selector = _selector_for(result["data_schema"], key)
assert isinstance(selector, SelectSelector), key
assert selector.config["mode"] == "dropdown", key
async def test_get_temperatures_does_not_log_its_own_error(
mock_hass, caplog: pytest.LogCaptureFixture
) -> None:
"""The coordinator logs a failed refresh, so the API layer must not."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_session.return_value.get.side_effect = aiohttp.ClientError("boom")
with caplog.at_level(logging.DEBUG), pytest.raises(SeaTemperatureError) as excinfo:
await api.get_temperatures("/europe/denmark/copenhagen/")
assert "boom" in str(excinfo.value)
assert [record for record in caplog.records if record.levelno >= logging.ERROR] == []
async def test_get_temperatures_rejects_an_invalid_path(mock_hass) -> None:
"""An unusable path is a failed refresh, not a silent None."""
api = SeaTemperatureAPI(mock_hass)
with pytest.raises(SeaTemperatureError):
await api.get_temperatures("https://example.com/evil/")
async def test_fetch_turns_an_api_error_into_update_failed() -> None:
"""The coordinator has to see one UpdateFailed carrying the reason."""
api = MagicMock()
api.get_temperatures = AsyncMock(side_effect=SeaTemperatureError("upstream is down"))
with pytest.raises(UpdateFailed, match="upstream is down"):
await _async_fetch(api, "/europe/denmark/copenhagen/", "Copenhagen")
async def test_every_request_carries_an_explicit_timeout(mock_hass) -> None:
"""A host that accepts the connection but never answers must not hang a refresh."""
api = SeaTemperatureAPI(mock_hass)
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
get = mock_session.return_value.get
mock_response = AsyncMock()
mock_response.text.return_value = load_fixture("copenhagen.html")
mock_response.json.return_value = {"locations": []}
mock_response.raise_for_status = MagicMock()
get.return_value.__aenter__.return_value = mock_response
await api.get_temperatures("/europe/denmark/copenhagen/")
await api.get_map_locations()
assert get.call_count == 2
for call in get.call_args_list:
assert call.kwargs["timeout"] is REQUEST_TIMEOUT
assert REQUEST_TIMEOUT.total is not None
assert REQUEST_TIMEOUT.total > 0
async def test_map_locations_are_cached_within_the_ttl(mock_hass) -> None:
"""Repeated lookups inside the TTL must not refetch 19k rows."""
api = SeaTemperatureAPI(mock_hass)
payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.return_value = payload
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
first = await api.get_map_locations()
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 1
assert first == second
assert "sea-1" in first
async def test_map_locations_are_refetched_once_the_ttl_expires(mock_hass) -> None:
"""A beach added upstream must appear without restarting Home Assistant."""
api = SeaTemperatureAPI(mock_hass)
first_payload = {
"locations": [["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"]]
}
second_payload = {
"locations": [
["sea-1", "Acharavi", "Greece", "Corfu", "/europe/greece/acharavi/"],
["sea-2", "Sylt", "Germany", "", "/europe/germany/island-of-sylt/"],
]
}
with patch(
"custom_components.seatemperatures.api.async_get_clientsession"
) as mock_session:
mock_response = AsyncMock()
mock_response.json.side_effect = [first_payload, second_payload]
mock_response.raise_for_status = MagicMock()
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
with patch(
"custom_components.seatemperatures.api.time.monotonic", return_value=0.0
):
first = await api.get_map_locations()
with patch(
"custom_components.seatemperatures.api.time.monotonic",
return_value=MAP_LOCATIONS_TTL + 1.0,
):
second = await api.get_map_locations()
assert mock_session.return_value.get.call_count == 2
assert "sea-2" not in first
assert "sea-2" in second