"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches httpx.get with canned responses. Covers category normalization (Open- Meteo's WMO codes, NWS's icon-URL/text shapes, EC's numeric icon codes) landing on the same shared category set, and each provider's fetch_current/fetch_hourly/fetch_daily shape. The EC fixtures below are trimmed-down real response shapes captured live against api.weather.gc.ca (Toronto, 2026-07-27), not guessed.""" from __future__ import annotations import httpx import pytest from app.weather import WeatherFetchError, ec, nws, open_meteo class _FakeResponse: def __init__(self, data: dict): self._data = data def raise_for_status(self) -> None: pass def json(self) -> dict: return self._data # --- open_meteo ----------------------------------------------------------- def test_open_meteo_fetch_current(monkeypatch): monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse( {"current": {"temperature_2m": 72.5, "weathercode": 3}} )) result = open_meteo.fetch_current(45.5, -122.6, "fahrenheit") assert result == {"temp": 72.5, "category": "cloudy"} def test_open_meteo_fetch_hourly_respects_hours_limit(monkeypatch): monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({ "hourly": { "time": [f"2026-07-27T{h:02d}:00" for h in range(24)], "temperature_2m": list(range(24)), "weathercode": [0] * 24, } })) result = open_meteo.fetch_hourly(45.5, -122.6, "fahrenheit", hours=6) assert len(result) == 6 assert result[0] == {"time": "2026-07-27T00:00", "temp": 0, "category": "clear"} def test_open_meteo_fetch_daily_normalizes_category_and_clamps_days(monkeypatch): monkeypatch.setattr(httpx, "get", lambda url, params, timeout: _FakeResponse({ "daily": { "time": ["2026-07-27", "2026-07-28", "2026-07-29"], "weathercode": [95, 71, 61], "temperature_2m_max": [80, 60, 65], "temperature_2m_min": [65, 45, 50], } })) result = open_meteo.fetch_daily(45.5, -122.6, "fahrenheit", days=2) assert list(result.keys()) == ["2026-07-27", "2026-07-28"] assert result["2026-07-27"] == {"high": 80, "low": 65, "category": "thunderstorm"} assert result["2026-07-28"]["category"] == "snow" def test_open_meteo_weather_category_unknown_code_falls_back_to_cloudy(): assert open_meteo.weather_category(9999) == "cloudy" def test_open_meteo_raises_weather_fetch_error_on_http_failure(monkeypatch): def _raise(*a, **kw): raise httpx.ConnectError("boom") monkeypatch.setattr(httpx, "get", _raise) with pytest.raises(WeatherFetchError): open_meteo.fetch_current(45.5, -122.6, "fahrenheit") # --- nws -------------------------------------------------------------------- @pytest.mark.parametrize("icon_url,expected", [ ("https://api.weather.gov/icons/land/day/skc?size=medium", "clear"), ("https://api.weather.gov/icons/land/night/few?size=medium", "partly_cloudy"), ("https://api.weather.gov/icons/land/day/bkn?size=medium", "cloudy"), ("https://api.weather.gov/icons/land/day/tsra,40?size=medium", "thunderstorm"), ("https://api.weather.gov/icons/land/night/wind_skc?size=medium", "clear"), ("https://api.weather.gov/icons/land/day/snow,80?size=medium", "snow"), ("https://api.weather.gov/icons/land/day/rain_showers,60?size=medium", "rain"), ]) def test_nws_category_from_icon(icon_url, expected): assert nws._category_from_icon(icon_url) == expected def test_nws_category_from_icon_unrecognized_code_falls_back_to_none(): assert nws._category_from_icon("https://api.weather.gov/icons/land/day/mystery_code?size=medium") is None @pytest.mark.parametrize("text,expected", [ ("Chance Thunderstorms", "thunderstorm"), ("Snow likely", "snow"), ("Rain showers", "rain"), ("Patchy Fog", "fog"), ("Mostly Sunny", "clear"), ("Something Unrelated", "cloudy"), ]) def test_nws_category_from_text_fallback(text, expected): assert nws._category_from_text(text) == expected def test_nws_convert_temp_fahrenheit_to_celsius(): assert round(nws._convert_temp(32, "F", "celsius"), 1) == 0.0 def test_nws_convert_temp_no_op_when_units_already_match(): assert nws._convert_temp(75, "F", "fahrenheit") == 75 def _points_response(): return _FakeResponse({"properties": { "forecast": "https://api.weather.gov/gridpoints/PQR/1,1/forecast", "forecastHourly": "https://api.weather.gov/gridpoints/PQR/1,1/forecast/hourly", }}) def _hourly_periods_response(n=6): return _FakeResponse({"properties": {"periods": [ {"startTime": f"2026-07-27T{h:02d}:00:00-07:00", "temperature": 60 + h, "temperatureUnit": "F", "shortForecast": "Sunny", "icon": "https://api.weather.gov/icons/land/day/skc?size=medium"} for h in range(n) ]}}) def test_nws_fetch_current_uses_first_hourly_period(monkeypatch): def _get(url, headers, timeout): if "/points/" in url: return _points_response() return _hourly_periods_response() monkeypatch.setattr(httpx, "get", _get) result = nws.fetch_current(45.5, -122.6, "fahrenheit") assert result == {"temp": 60, "category": "clear"} def test_nws_fetch_hourly_respects_hours_limit(monkeypatch): def _get(url, headers, timeout): if "/points/" in url: return _points_response() return _hourly_periods_response(n=24) monkeypatch.setattr(httpx, "get", _get) result = nws.fetch_hourly(45.5, -122.6, "fahrenheit", hours=4) assert len(result) == 4 assert result[0]["category"] == "clear" def test_nws_fetch_daily_pairs_day_night_periods_by_date(monkeypatch): def _get(url, headers, timeout): if "/points/" in url: return _points_response() return _FakeResponse({"properties": {"periods": [ {"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80, "temperatureUnit": "F", "shortForecast": "Sunny", "icon": "https://api.weather.gov/icons/land/day/skc?size=medium"}, {"startTime": "2026-07-27T18:00:00-07:00", "isDaytime": False, "temperature": 60, "temperatureUnit": "F", "shortForecast": "Clear", "icon": "https://api.weather.gov/icons/land/night/skc?size=medium"}, {"startTime": "2026-07-28T06:00:00-07:00", "isDaytime": True, "temperature": 75, "temperatureUnit": "F", "shortForecast": "Rain", "icon": "https://api.weather.gov/icons/land/day/rain,80?size=medium"}, {"startTime": "2026-07-28T18:00:00-07:00", "isDaytime": False, "temperature": 55, "temperatureUnit": "F", "shortForecast": "Rain", "icon": "https://api.weather.gov/icons/land/night/rain,80?size=medium"}, ]}}) monkeypatch.setattr(httpx, "get", _get) result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=2) assert result == { "2026-07-27": {"high": 80, "low": 60, "category": "clear"}, "2026-07-28": {"high": 75, "low": 55, "category": "rain"}, } def test_nws_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch): """Asking for more days than NWS actually returned degrades gracefully (fewer days), not an error -- same idiom as Open-Meteo's own clamp.""" def _get(url, headers, timeout): if "/points/" in url: return _points_response() return _FakeResponse({"properties": {"periods": [ {"startTime": "2026-07-27T06:00:00-07:00", "isDaytime": True, "temperature": 80, "temperatureUnit": "F", "shortForecast": "Sunny", "icon": "https://api.weather.gov/icons/land/day/skc?size=medium"}, ]}}) monkeypatch.setattr(httpx, "get", _get) result = nws.fetch_daily(45.5, -122.6, "fahrenheit", days=7) assert len(result) == 1 def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch): def _raise(*a, **kw): raise httpx.ConnectError("boom") monkeypatch.setattr(httpx, "get", _raise) with pytest.raises(WeatherFetchError): nws.fetch_current(45.5, -122.6, "fahrenheit") # --- ec --------------------------------------------------------------------- def _ec_feature(lon: float, lat: float, properties: dict) -> dict: return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": properties} def _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") -> dict: return { "currentConditions": { "temperature": {"value": {"en": temp_c}}, "iconCode": {"value": icon_code}, "condition": {"en": condition}, }, "hourlyForecastGroup": {"hourlyForecasts": []}, "forecastGroup": {"timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": []}, } def _ec_items_response(features: list[dict]) -> _FakeResponse: return _FakeResponse({"features": features}) @pytest.mark.parametrize("code,expected", [ (0, "clear"), (1, "clear"), (30, "clear"), (2, "partly_cloudy"), (5, "partly_cloudy"), (31, "partly_cloudy"), (32, "partly_cloudy"), (3, "cloudy"), (4, "cloudy"), (10, "cloudy"), (33, "cloudy"), (6, "rain"), (12, "rain"), (28, "rain"), (36, "rain"), (9, "thunderstorm"), (19, "thunderstorm"), (39, "thunderstorm"), (24, "fog"), ]) def test_ec_category_from_known_codes(code, expected): assert ec._category_from_code_and_text(code, "irrelevant text") == expected @pytest.mark.parametrize("text,expected", [ ("Periods of snow", "snow"), ("Flurries", "snow"), ("Freezing rain", "snow"), ("Periods of rain", "rain"), ("Chance of showers", "rain"), ("Drizzle", "rain"), ("Risk of thunderstorms", "thunderstorm"), ("Tornado warning", "thunderstorm"), ("Patchy fog", "fog"), ("Mainly sunny", "clear"), ("Something unrelated", "cloudy"), ]) def test_ec_category_from_text_fallback_for_unmapped_code(text, expected): assert ec._category_from_code_and_text(9999, text) == expected def test_ec_convert_temp_celsius_to_fahrenheit(): assert ec._convert_temp(0, "fahrenheit") == 32 assert ec._convert_temp(0, "celsius") == 0 def test_ec_fetch_current_converts_and_categorizes(monkeypatch): props = _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response( [_ec_feature(-79.38, 43.65, props)] )) result = ec.fetch_current(43.65, -79.38, "fahrenheit") assert result == {"temp": pytest.approx(83.84), "category": "cloudy"} def test_ec_fetch_hourly_respects_hours_limit(monkeypatch): hourly = [ {"timestamp": f"2026-07-27T{h:02d}:00:00Z", "temperature": {"value": {"en": 20 + h}}, "iconCode": {"value": 2}, "condition": {"en": "A mix of sun and cloud"}} for h in range(24) ] props = _ec_current_properties() props["hourlyForecastGroup"] = {"hourlyForecasts": hourly} monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response( [_ec_feature(-79.38, 43.65, props)] )) result = ec.fetch_hourly(43.65, -79.38, "celsius", hours=5) assert len(result) == 5 assert result[0]["category"] == "partly_cloudy" def _ec_forecast_period(name: str, temp_class: str, temp_c: float, icon_code: int, summary: str) -> dict: return { "period": {"textForecastName": {"en": name}}, "temperatures": {"temperature": [{"value": {"en": temp_c}, "class": {"en": temp_class}}]}, "abbreviatedForecast": {"icon": {"value": icon_code}, "textSummary": {"en": summary}}, } def test_ec_fetch_daily_pairs_day_night_periods_by_walking_order(monkeypatch): props = _ec_current_properties() props["forecastGroup"] = { "timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": [ _ec_forecast_period("Today", "high", 29, 9, "Chance of showers"), _ec_forecast_period("Tonight", "low", 15, 30, "Clear"), _ec_forecast_period("Tuesday", "high", 25, 2, "Partly cloudy"), _ec_forecast_period("Tuesday night", "low", 17, 32, "Cloudy periods"), ], } monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response( [_ec_feature(-79.38, 43.65, props)] )) result = ec.fetch_daily(43.65, -79.38, "celsius", days=2) assert result == { "2026-07-27": {"high": 29, "low": 15, "category": "thunderstorm"}, "2026-07-28": {"high": 25, "low": 17, "category": "partly_cloudy"}, } def test_ec_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch): props = _ec_current_properties() props["forecastGroup"] = { "timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": [_ec_forecast_period("Today", "high", 29, 9, "Chance of showers")], } monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response( [_ec_feature(-79.38, 43.65, props)] )) result = ec.fetch_daily(43.65, -79.38, "celsius", days=7) assert len(result) == 1 def test_ec_nearest_site_widens_bbox_until_within_max_distance(monkeypatch): """A site right at the query point should be picked up by the very first (smallest) bounding box -- no need to widen.""" props = _ec_current_properties() calls = [] def _get(url, params, headers, timeout): calls.append(params["bbox"]) return _ec_items_response([_ec_feature(-79.38, 43.65, props)]) monkeypatch.setattr(httpx, "get", _get) result = ec.fetch_current(43.65, -79.38, "celsius") assert result["temp"] == pytest.approx(28.8) assert len(calls) == 1 # first (smallest) padding already found it def test_ec_nearest_site_rejects_a_match_beyond_max_distance(monkeypatch): """Regression test: without a distance cutoff, progressively widening the bbox until non-empty eventually matches ANY location on Earth to some real EC site once the box is big enough (confirmed live: a Miami, FL query matched Leamington, Ontario, 1824 km away). A site ~785 km from the query point must be rejected as "not covered", not returned as if it were a legitimate nearby match.""" far_props = _ec_current_properties() widest_bbox = f"{-ec._BBOX_PADDINGS_DEG[-1]},{-ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]}" def _get(url, params, headers, timeout): # Only the widest padding's box actually reaches the far site -- # every narrower one comes back empty, same as a genuine gap. if params["bbox"] == widest_bbox: return _ec_items_response([_ec_feature(5.0, 5.0, far_props)]) return _ec_items_response([]) monkeypatch.setattr(httpx, "get", _get) with pytest.raises(WeatherFetchError): ec.fetch_current(0.0, 0.0, "celsius") def test_ec_raises_weather_fetch_error_on_http_failure(monkeypatch): def _raise(*a, **kw): raise httpx.ConnectError("boom") monkeypatch.setattr(httpx, "get", _raise) with pytest.raises(WeatherFetchError): ec.fetch_current(43.65, -79.38, "fahrenheit")