338 lines
11 KiB
Python
338 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from custom_components.seatemperatures import async_migrate_entry
|
|
from custom_components.seatemperatures.api import (
|
|
SeaTemperatureAPI,
|
|
parse_map_locations,
|
|
parse_search_results,
|
|
)
|
|
from custom_components.seatemperatures.config_flow import SeaTemperatureConfigFlow
|
|
from custom_components.seatemperatures.const import (
|
|
CONF_AREA,
|
|
CONF_CONTINENT,
|
|
CONF_COUNTRY,
|
|
CONF_PATH,
|
|
CONF_PLACE,
|
|
CONF_PLACE_ID,
|
|
)
|
|
from custom_components.seatemperatures.parser import (
|
|
parse_location_page,
|
|
validate_location_path,
|
|
)
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_hass():
|
|
"""Mock Home Assistant object."""
|
|
hass = AsyncMock()
|
|
hass.data = {}
|
|
hass.config_entries = SimpleNamespace(async_update_entry=MagicMock())
|
|
return hass
|
|
|
|
|
|
def load_fixture(name: str) -> str:
|
|
"""Load an HTML fixture by name."""
|
|
return (FIXTURES_DIR / name).read_text(encoding="utf-8")
|
|
|
|
|
|
async def test_parse_location_page_from_copenhagen_fixture() -> None:
|
|
"""Parse a standard location page fixture."""
|
|
data = parse_location_page(load_fixture("copenhagen.html"))
|
|
|
|
assert data.date == "2026-05-21"
|
|
assert data.today == pytest.approx(11.83)
|
|
assert data.yesterday == pytest.approx(11.7)
|
|
assert data.average_avg == pytest.approx(12.126000000000001)
|
|
assert data.average_min == pytest.approx(10.85)
|
|
assert data.average_max == pytest.approx(13.67)
|
|
assert data.last_week == pytest.approx(10.31)
|
|
assert data.trend_labels is not None
|
|
assert data.trend_labels[0] == "04-22"
|
|
assert data.trend_labels[-1] == "05-21"
|
|
assert data.trend_temps_c is not None
|
|
assert data.trend_temps_c[-1] == pytest.approx(11.83)
|
|
|
|
|
|
async def test_parse_location_page_is_not_location_specific() -> None:
|
|
"""Parse a second fixture to prove the parser is generic."""
|
|
data = parse_location_page(load_fixture("miami_beach.html"))
|
|
|
|
assert data.today == pytest.approx(27.97)
|
|
assert data.yesterday == pytest.approx(27.95)
|
|
assert data.average_avg == pytest.approx(27.784999999999997)
|
|
assert data.average_min == pytest.approx(26.47)
|
|
assert data.average_max == pytest.approx(28.71)
|
|
assert data.last_week == pytest.approx(28.5)
|
|
|
|
|
|
async def test_parse_location_page_tolerates_missing_values() -> None:
|
|
"""Missing values should produce None instead of crashing."""
|
|
data = parse_location_page("<html><body><h1>No chart</h1></body></html>")
|
|
|
|
assert data.today is None
|
|
assert data.yesterday is None
|
|
assert data.average_avg is None
|
|
assert data.average_min is None
|
|
assert data.average_max is None
|
|
assert data.last_week is None
|
|
assert data.trend_labels is None
|
|
assert data.trend_temps_c is None
|
|
|
|
|
|
async def test_parse_location_page_tolerates_malformed_chart_json(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""Malformed chart JSON should be ignored with a warning."""
|
|
html = """
|
|
<html><body>
|
|
<p>Today</p><p><span data-c="15.5">15.5</span></p>
|
|
<script type="application/json" data-sea-curve-payload>{broken json</script>
|
|
</body></html>
|
|
"""
|
|
|
|
data = parse_location_page(html)
|
|
|
|
assert data.today == pytest.approx(15.5)
|
|
assert data.trend_labels is None
|
|
assert "Failed to parse SeaTemperatures chart payload" in caplog.text
|
|
|
|
|
|
async def test_validate_location_path() -> None:
|
|
"""Location paths should be normalized and validated."""
|
|
assert (
|
|
validate_location_path("/north-america/united-states/miami-beach")
|
|
== "/north-america/united-states/miami-beach/"
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
validate_location_path("https://seatemperatures.net/europe/denmark/copenhagen/")
|
|
|
|
with pytest.raises(ValueError):
|
|
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 = {
|
|
"locations": [
|
|
[
|
|
"sea-1",
|
|
"Ain El Turk",
|
|
"Algeria",
|
|
"",
|
|
"/africa/algeria/ain-el-turk/",
|
|
35.7438,
|
|
-0.7693,
|
|
19.1,
|
|
],
|
|
["broken"],
|
|
]
|
|
}
|
|
|
|
results = parse_map_locations(data)
|
|
|
|
assert results == {
|
|
"sea-1": {
|
|
"name": "Ain El Turk",
|
|
"country": "Algeria",
|
|
"area": "",
|
|
"path": "/africa/algeria/ain-el-turk/",
|
|
}
|
|
}
|
|
|
|
|
|
async def test_get_temperatures_from_location_page(mock_hass) -> None:
|
|
"""The API should fetch HTML and return the legacy payload shape."""
|
|
api = SeaTemperatureAPI(mock_hass)
|
|
html = load_fixture("copenhagen.html")
|
|
|
|
with patch(
|
|
"custom_components.seatemperatures.api.async_get_clientsession"
|
|
) as mock_session:
|
|
mock_response = AsyncMock()
|
|
mock_response.text.return_value = html
|
|
mock_response.raise_for_status = MagicMock()
|
|
mock_session.return_value.get.return_value.__aenter__.return_value = mock_response
|
|
|
|
result = await api.get_temperatures("/europe/denmark/copenhagen/")
|
|
|
|
assert result is not None
|
|
assert result["date"] == "2026-05-21"
|
|
assert result["sst"]["today"] == pytest.approx(11.83)
|
|
assert result["sst"]["yesterday"] == pytest.approx(11.7)
|
|
assert result["sst"]["last_week"] == pytest.approx(10.31)
|
|
assert result["sst"]["average"]["avg"] == pytest.approx(12.126000000000001)
|
|
assert result["charts"]["last_thirty"]["labels"][-1] == "05-21"
|
|
|
|
|
|
async def test_get_location_by_place_id_uses_map_locations(mock_hass) -> None:
|
|
"""Legacy IDs should resolve through map-locations data."""
|
|
api = SeaTemperatureAPI(mock_hass)
|
|
payload = {
|
|
"locations": [["canonical-36", "Boumerdas", "Algeria", "Boumerdes", "/africa/algeria/boumerdas/"]]
|
|
}
|
|
|
|
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
|
|
|
|
result = await api.get_location_by_place_id("canonical-36")
|
|
|
|
assert result == {
|
|
"name": "Boumerdas",
|
|
"country": "Algeria",
|
|
"area": "Boumerdes",
|
|
"path": "/africa/algeria/boumerdas/",
|
|
}
|
|
|
|
|
|
async def test_get_location_by_place_id_falls_back_to_sea_prefix(mock_hass) -> None:
|
|
"""Numeric legacy IDs should resolve by prefixing sea-."""
|
|
api = SeaTemperatureAPI(mock_hass)
|
|
payload = {
|
|
"locations": [["sea-5484", "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
|
|
|
|
result = await api.get_location_by_place_id("5484")
|
|
|
|
assert result == {
|
|
"name": "Acharavi",
|
|
"country": "Greece",
|
|
"area": "Corfu",
|
|
"path": "/europe/greece/acharavi/",
|
|
}
|
|
|
|
|
|
async def test_config_flow_stores_path_based_location(mock_hass) -> None:
|
|
"""Config flow should store location metadata including the path."""
|
|
flow = SeaTemperatureConfigFlow()
|
|
flow.hass = mock_hass
|
|
|
|
locations_cache = {
|
|
"sea-1": {
|
|
"name": "Miami Beach",
|
|
"country": "United States",
|
|
"area": "Florida, United States",
|
|
"path": "/north-america/united-states/miami-beach/",
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
user_result = await flow.async_step_user({CONF_CONTINENT: "North America"})
|
|
assert user_result["type"] == "form"
|
|
assert user_result["step_id"] == "country"
|
|
|
|
country_result = await flow.async_step_country({CONF_COUNTRY: "United States"})
|
|
assert country_result["type"] == "form"
|
|
assert country_result["step_id"] == "place"
|
|
|
|
with patch.object(flow, "async_set_unique_id", AsyncMock()), patch.object(
|
|
flow, "_abort_if_unique_id_configured", MagicMock()
|
|
):
|
|
place_result = await flow.async_step_place(
|
|
{CONF_PLACE: "Miami Beach (Florida, United States)"}
|
|
)
|
|
|
|
assert place_result["type"] == "create_entry"
|
|
assert place_result["data"] == {
|
|
CONF_CONTINENT: "North America",
|
|
CONF_COUNTRY: "United States",
|
|
CONF_AREA: "Florida, United States",
|
|
CONF_PLACE: "Miami Beach",
|
|
CONF_PATH: "/north-america/united-states/miami-beach/",
|
|
}
|
|
|
|
|
|
async def test_async_migrate_entry_from_place_id(mock_hass) -> None:
|
|
"""Legacy config entries should migrate from place_id to path."""
|
|
entry = SimpleNamespace(
|
|
version=1,
|
|
entry_id="entry-1",
|
|
data={CONF_PLACE_ID: "sea-1", CONF_PLACE: "Legacy Name"},
|
|
)
|
|
|
|
with patch.object(
|
|
SeaTemperatureAPI,
|
|
"get_location_by_place_id",
|
|
AsyncMock(
|
|
return_value={
|
|
"name": "Ain El Turk",
|
|
"country": "Algeria",
|
|
"area": "",
|
|
"path": "/africa/algeria/ain-el-turk/",
|
|
}
|
|
),
|
|
):
|
|
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
|
|
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
|
|
|
|
|
|
async def test_async_migrate_entry_fails_when_place_id_cannot_be_mapped(mock_hass) -> None:
|
|
"""Migration should fail cleanly if a legacy ID no longer resolves."""
|
|
entry = SimpleNamespace(version=1, entry_id="entry-2", data={CONF_PLACE_ID: "missing"})
|
|
|
|
with patch.object(SeaTemperatureAPI, "get_location_by_place_id", AsyncMock(return_value=None)):
|
|
migrated = await async_migrate_entry(mock_hass, entry)
|
|
|
|
assert migrated is False
|
|
mock_hass.config_entries.async_update_entry.assert_not_called()
|