2026-05-29 17:20:07 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
from src.data_collection.weather_sources import WeatherDataCollector
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collector(monkeypatch, tmp_path) -> WeatherDataCollector:
|
|
|
|
|
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
|
|
|
|
return WeatherDataCollector({"weather": {}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fetch_wunderground_historical_uses_weather_com_historical_json(monkeypatch, tmp_path):
|
|
|
|
|
collector = _collector(monkeypatch, tmp_path)
|
|
|
|
|
called = {}
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **_kwargs):
|
|
|
|
|
called["url"] = url
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"metadata": {
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"location_id": "ZSPD:9:CN",
|
|
|
|
|
"units": "m",
|
|
|
|
|
},
|
|
|
|
|
"observations": [
|
|
|
|
|
{
|
|
|
|
|
"obs_id": "ZSPD",
|
|
|
|
|
"obs_name": "Shanghai/Pudong",
|
|
|
|
|
"valid_time_gmt": 1780027200,
|
|
|
|
|
"temp": 25,
|
|
|
|
|
"wx_phrase": "Fair",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"obs_id": "ZSPD",
|
|
|
|
|
"obs_name": "Shanghai/Pudong",
|
|
|
|
|
"valid_time_gmt": 1780029000,
|
|
|
|
|
"temp": 26,
|
|
|
|
|
"wx_phrase": "Fair",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"obs_id": "ZSPD",
|
|
|
|
|
"obs_name": "Shanghai/Pudong",
|
|
|
|
|
"valid_time_gmt": 1780032600,
|
|
|
|
|
"temp": 26,
|
|
|
|
|
"wx_phrase": "Fair",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
request=httpx.Request("GET", url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(collector, "_http_get", fake_get)
|
|
|
|
|
|
|
|
|
|
payload = collector.fetch_wunderground_historical(
|
|
|
|
|
"shanghai",
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
utc_offset=28800,
|
|
|
|
|
local_date="2026-05-29",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parsed = urlparse(called["url"])
|
|
|
|
|
query = parse_qs(parsed.query)
|
|
|
|
|
assert parsed.path == "/v1/location/ZSPD:9:CN/observations/historical.json"
|
|
|
|
|
assert query["units"] == ["m"]
|
|
|
|
|
assert query["startDate"] == ["20260529"]
|
|
|
|
|
assert query["endDate"] == ["20260529"]
|
|
|
|
|
assert payload["source"] == "wunderground_historical"
|
|
|
|
|
assert payload["station_code"] == "ZSPD"
|
|
|
|
|
assert payload["location_id"] == "ZSPD:9:CN"
|
|
|
|
|
assert payload["temp"] == 26
|
|
|
|
|
assert payload["max_so_far"] == 26
|
|
|
|
|
assert payload["max_temp_time"] == "12:30"
|
|
|
|
|
assert payload["obs_time"] == "13:30"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fetch_wunderground_historical_uses_current_json_to_lift_latest_and_high(monkeypatch, tmp_path):
|
|
|
|
|
collector = _collector(monkeypatch, tmp_path)
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **kwargs):
|
|
|
|
|
calls.append({"url": url, "headers": kwargs.get("headers") or {}})
|
|
|
|
|
if urlparse(url).path.endswith("/current.json"):
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"metadata": {
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"location_id": "ZGGG:9:CN",
|
|
|
|
|
"units": "m",
|
|
|
|
|
},
|
|
|
|
|
"observation": {
|
|
|
|
|
"obs_time": 1780039020,
|
|
|
|
|
"obs_time_local": "2026-05-29T15:17:00+0800",
|
|
|
|
|
"metric": {
|
|
|
|
|
"temp": 38,
|
|
|
|
|
"temp_max_24hour": 38,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
request=httpx.Request("GET", url),
|
|
|
|
|
)
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"metadata": {
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"location_id": "ZGGG:9:CN",
|
|
|
|
|
"units": "m",
|
|
|
|
|
},
|
|
|
|
|
"observations": [
|
|
|
|
|
{
|
|
|
|
|
"obs_id": "ZGGG",
|
|
|
|
|
"valid_time_gmt": 1780034400,
|
|
|
|
|
"temp": 36,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
request=httpx.Request("GET", url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(collector, "_http_get", fake_get)
|
|
|
|
|
|
|
|
|
|
payload = collector.fetch_wunderground_historical(
|
|
|
|
|
"guangzhou",
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
utc_offset=28800,
|
|
|
|
|
local_date="2026-05-29",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
paths = [urlparse(call["url"]).path for call in calls]
|
|
|
|
|
assert "/v1/location/ZGGG:9:CN/observations/historical.json" in paths
|
|
|
|
|
assert "/v1/location/ZGGG:9:CN/observations/current.json" in paths
|
|
|
|
|
assert all(call["headers"].get("Cache-Control") == "no-cache" for call in calls)
|
|
|
|
|
assert all(call["headers"].get("Pragma") == "no-cache" for call in calls)
|
|
|
|
|
assert payload["temp"] == 38
|
|
|
|
|
assert payload["obs_time"] == "15:17"
|
|
|
|
|
assert payload["max_so_far"] == 38
|
|
|
|
|
assert payload["max_temp_time"] == "15:17"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_fetch_wunderground_historical_keeps_fahrenheit_for_us_cities(monkeypatch, tmp_path):
|
|
|
|
|
collector = _collector(monkeypatch, tmp_path)
|
|
|
|
|
called = {}
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **_kwargs):
|
|
|
|
|
called["url"] = url
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
200,
|
|
|
|
|
json={
|
|
|
|
|
"metadata": {
|
|
|
|
|
"status_code": 200,
|
|
|
|
|
"location_id": "KLGA:9:US",
|
|
|
|
|
"units": "e",
|
|
|
|
|
},
|
|
|
|
|
"observations": [
|
|
|
|
|
{"obs_id": "KLGA", "valid_time_gmt": 1780027200, "temp": 73},
|
|
|
|
|
{"obs_id": "KLGA", "valid_time_gmt": 1780030800, "temp": 75},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
request=httpx.Request("GET", url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(collector, "_http_get", fake_get)
|
|
|
|
|
|
|
|
|
|
payload = collector.fetch_wunderground_historical(
|
|
|
|
|
"new york",
|
|
|
|
|
use_fahrenheit=True,
|
|
|
|
|
utc_offset=-14400,
|
|
|
|
|
local_date="2026-05-29",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
parsed = urlparse(called["url"])
|
|
|
|
|
assert parsed.path == "/v1/location/KLGA:9:US/observations/historical.json"
|
|
|
|
|
assert parse_qs(parsed.query)["units"] == ["e"]
|
|
|
|
|
assert payload["temp_symbol"] == "°F"
|
|
|
|
|
assert payload["max_so_far"] == 75
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 18:28:50 +08:00
|
|
|
def test_fetch_wunderground_historical_negative_caches_client_errors(monkeypatch, tmp_path):
|
|
|
|
|
collector = _collector(monkeypatch, tmp_path)
|
|
|
|
|
monkeypatch.setenv("WUNDERGROUND_NEGATIVE_CACHE_TTL_SEC", "900")
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def fake_get(url: str, **_kwargs):
|
|
|
|
|
calls.append(url)
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
400,
|
|
|
|
|
json={"errors": [{"message": "invalid location"}]},
|
|
|
|
|
request=httpx.Request("GET", url),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(collector, "_http_get", fake_get)
|
|
|
|
|
|
|
|
|
|
first = collector.fetch_wunderground_historical(
|
|
|
|
|
"istanbul",
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
utc_offset=10800,
|
|
|
|
|
local_date="2026-05-31",
|
|
|
|
|
)
|
|
|
|
|
second = collector.fetch_wunderground_historical(
|
|
|
|
|
"istanbul",
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
utc_offset=10800,
|
|
|
|
|
local_date="2026-05-31",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert first is None
|
|
|
|
|
assert second is None
|
|
|
|
|
assert len(calls) == 2
|
|
|
|
|
|
|
|
|
|
collector._evict_city_caches(
|
|
|
|
|
city="istanbul",
|
|
|
|
|
lat=None,
|
|
|
|
|
lon=None,
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
)
|
|
|
|
|
third = collector.fetch_wunderground_historical(
|
|
|
|
|
"istanbul",
|
|
|
|
|
use_fahrenheit=False,
|
|
|
|
|
utc_offset=10800,
|
|
|
|
|
local_date="2026-05-31",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert third is None
|
|
|
|
|
assert len(calls) == 4
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 23:32:34 +08:00
|
|
|
def test_fetch_all_sources_skips_wunderground_historical_by_default(monkeypatch, tmp_path):
|
2026-05-29 17:20:07 +08:00
|
|
|
collector = _collector(monkeypatch, tmp_path)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(collector, "fetch_settlement_current", lambda _city: None)
|
|
|
|
|
monkeypatch.setattr(collector, "_supports_aviationweather", lambda _city: False)
|
2026-06-14 23:32:34 +08:00
|
|
|
calls = []
|
2026-05-29 17:20:07 +08:00
|
|
|
|
2026-06-14 23:32:34 +08:00
|
|
|
def fail_fetch(*args, **kwargs):
|
|
|
|
|
calls.append((args, kwargs))
|
|
|
|
|
raise AssertionError("fetch_all_sources must not fetch Wunderground by default")
|
2026-05-29 17:20:07 +08:00
|
|
|
|
2026-06-14 23:32:34 +08:00
|
|
|
monkeypatch.setattr(collector, "fetch_wunderground_historical", fail_fetch)
|
2026-05-29 17:20:07 +08:00
|
|
|
|
|
|
|
|
payload = collector.fetch_all_sources("shanghai")
|
|
|
|
|
|
2026-06-14 23:32:34 +08:00
|
|
|
assert "wunderground_current" not in payload
|
|
|
|
|
assert calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_analyze_summary_skips_wunderground_historical_by_default(monkeypatch):
|
|
|
|
|
import web.analysis_service as analysis_service
|
|
|
|
|
|
|
|
|
|
class FakeWeather:
|
|
|
|
|
TURKISH_PROVINCES = {}
|
|
|
|
|
wunderground_calls = 0
|
|
|
|
|
|
|
|
|
|
def fetch_settlement_current(self, _city):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
def fetch_wunderground_historical(self, *_args, **_kwargs):
|
|
|
|
|
self.wunderground_calls += 1
|
|
|
|
|
raise AssertionError("_analyze_summary must not fetch Wunderground by default")
|
|
|
|
|
|
|
|
|
|
def fetch_from_open_meteo(self, *_args, **_kwargs):
|
|
|
|
|
return {
|
|
|
|
|
"utc_offset": 28800,
|
|
|
|
|
"daily": {
|
|
|
|
|
"time": ["2026-06-14"],
|
|
|
|
|
"temperature_2m_max": [30.0],
|
|
|
|
|
},
|
|
|
|
|
"hourly": {
|
|
|
|
|
"time": [],
|
|
|
|
|
"temperature_2m": [],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def fetch_multi_model(self, *_args, **_kwargs):
|
|
|
|
|
return {"forecasts": {"Open-Meteo": 30.0}, "daily_forecasts": {}}
|
|
|
|
|
|
|
|
|
|
def _supports_aviationweather(self, _city):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def fetch_nws(self, *_args, **_kwargs):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
fake_weather = FakeWeather()
|
|
|
|
|
monkeypatch.setattr(analysis_service, "_weather", fake_weather)
|
|
|
|
|
monkeypatch.setattr(analysis_service, "_get_cached_analysis", lambda *_args, **_kwargs: None)
|
|
|
|
|
monkeypatch.setattr(analysis_service, "_get_cached_summary", lambda *_args, **_kwargs: None)
|
|
|
|
|
monkeypatch.setattr(analysis_service, "calculate_deb_prediction", lambda *_args, **_kwargs: {"prediction": 30.0})
|
|
|
|
|
monkeypatch.setattr(analysis_service, "_archive_intraday_path_snapshot", lambda *_args, **_kwargs: None)
|
|
|
|
|
|
|
|
|
|
payload = analysis_service._analyze_summary("shanghai", force_refresh=False)
|
|
|
|
|
|
|
|
|
|
assert payload["wunderground_current"] == {}
|
|
|
|
|
assert fake_weather.wunderground_calls == 0
|