Files
2026-06-11 11:50:50 -04:00

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