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

No chart

") 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 = """

Today

15.5

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