app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.
EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.
Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""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")
|