Stabilize AMSC chart detail overlays

This commit is contained in:
2569718930@qq.com
2026-06-15 03:13:08 +08:00
parent 39977e4431
commit 6b9caf8dc2
6 changed files with 152 additions and 6 deletions
@@ -253,6 +253,13 @@ export async function runTests() {
chartLogicSource.includes("primeCityDetailCache"), chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache", "visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
); );
assert(
chartLogicSource.includes('from "@/lib/backend-api"') &&
chartLogicSource.includes("buildBrowserBackendHeaders") &&
chartLogicSource.includes('await buildBrowserBackendHeaders({ Accept: "application/json" })') &&
chartLogicSource.includes("headers,"),
"terminal chart detail batch fetches must attach the browser Supabase bearer so users without Supabase cookies do not get backend 401 and empty charts",
);
assert( assert(
chartLogicSource.includes('scope: "chart"') && chartLogicSource.includes('scope: "chart"') &&
chartLogicSource.includes("params.toString()"), chartLogicSource.includes("params.toString()"),
@@ -267,7 +274,7 @@ export async function runTests() {
chartLogicSource.includes("missing?: string[]"), chartLogicSource.includes("missing?: string[]"),
"frontend city detail batch payload should understand partial responses and missing city markers", "frontend city detail batch payload should understand partial responses and missing city markers",
); );
const flushCityDetailBatchBlock = chartLogicSource.match(/async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailBatchWithTimeout/)?.[0] || ""; const flushCityDetailBatchBlock = chartLogicSource.match(/async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\n(?:async\s+)?function fetchCityDetailBatchWithTimeout/)?.[0] || "";
assert( assert(
flushCityDetailBatchBlock.includes("partialMissingCities") && flushCityDetailBatchBlock.includes("partialMissingCities") &&
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") && flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") &&
@@ -12,6 +12,7 @@ import type {
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths"; import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy"; import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
import { buildBrowserBackendHeaders } from "@/lib/backend-api";
import type { CityPatch } from "@/hooks/use-sse-patches"; import type { CityPatch } from "@/hooks/use-sse-patches";
const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000; const ROLLING_WINDOW_BEFORE_MS = 12 * 60 * 60 * 1000;
const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000; const ROLLING_WINDOW_AFTER_LIVE_MS = 2 * 60 * 60 * 1000;
@@ -1715,7 +1716,7 @@ function resolveAllBatchWaitersAsNull(
}); });
} }
function fetchCityDetailBatchWithTimeout( async function fetchCityDetailBatchWithTimeout(
cities: string[], cities: string[],
resolution: string, resolution: string,
forceRefresh: boolean, forceRefresh: boolean,
@@ -1730,8 +1731,9 @@ function fetchCityDetailBatchWithTimeout(
resolution, resolution,
scope: "chart", scope: "chart",
}); });
const headers = await buildBrowserBackendHeaders({ Accept: "application/json" });
return fetch(`/api/cities/detail-batch?${params.toString()}`, { return fetch(`/api/cities/detail-batch?${params.toString()}`, {
headers: { Accept: "application/json" }, headers,
signal: controller.signal, signal: controller.signal,
}) })
.then(async (res) => { .then(async (res) => {
+52
View File
@@ -46,3 +46,55 @@ def test_overlay_replaces_amos_when_old_local_time_string_looks_later_than_new_u
assert result["amos"]["observation_time"] == "2026-06-14T17:23:00+00:00" assert result["amos"]["observation_time"] == "2026-06-14T17:23:00+00:00"
assert result["amos"]["observation_time_local"] == "2026-06-15 01:23:00" assert result["amos"]["observation_time_local"] == "2026-06-15 01:23:00"
assert result["amos"]["runway_obs"]["point_temperatures"][0]["runway"] == "17L/35R" assert result["amos"]["runway_obs"]["point_temperatures"][0]["runway"] == "17L/35R"
def test_overlay_uses_latest_success_when_newer_status_row_has_no_observation():
class FakeDB:
def get_latest_raw_observation(self, source, city):
assert (source, city) == ("amsc_awos", "chengdu")
return {
"status": "no_results",
"observed_at": "",
"fetched_at": "2026-06-14T17:02:09+00:00",
"updated_at_ts": 1781456529.0,
"payload": {
"source": "amsc_awos",
"city": "chengdu",
"status": "no_results",
"error": "source returned no observation rows",
},
}
def list_latest_raw_observations_for_city(self, city, *, limit=100):
assert city == "chengdu"
return [
self.get_latest_raw_observation("amsc_awos", "chengdu"),
{
"source": "amsc_awos",
"city": "chengdu",
"station_code": "ZUUU",
"station_name": "Chengdu Shuangliu",
"status": "ok",
"observed_at": "2026-06-14T17:00:00+00:00",
"fetched_at": "2026-06-14T17:00:30+00:00",
"updated_at_ts": 1781456430.0,
"payload": {
"source": "amsc_awos",
"source_label": "AMSC AWOS Chengdu Shuangliu (ZUUU)",
"icao": "ZUUU",
"temp_c": 25.8,
"observation_time": "2026-06-14T17:00:00+00:00",
"observation_time_local": "2026-06-15 01:00:00",
},
},
]
result = overlay_latest_amsc_observation(
FakeDB(),
"chengdu",
{"name": "chengdu", "temp_symbol": "°C", "amos": {}},
)
assert result["amos"]["temp_c"] == 25.8
assert result["current"]["temp"] == 25.8
assert result["airport_current"]["source_code"] == "amsc_awos"
+56
View File
@@ -1199,6 +1199,62 @@ def test_chart_data_cache_hit_starts_full_stale_refresh(monkeypatch):
assert refresh_calls == ["paris"] assert refresh_calls == ["paris"]
def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch):
import asyncio
class FakeCache:
def get_city_cache(self, kind, city):
assert kind == "full"
return {
"payload": {
"name": city,
"display_name": city.title(),
"temp_symbol": "°C",
"risk": {"icao": "ZUUU"},
"current": {},
"airport_current": {},
"amos": {},
"hourly": {"times": ["13:00"], "temps": [25.0]},
},
}
def get_runway_obs_recent(self, icao, minutes=60):
return []
def get_latest_raw_observation(self, source, city):
assert (source, city) == ("amsc_awos", "chengdu")
return {
"source": "amsc_awos",
"city": "chengdu",
"station_code": "ZUUU",
"station_name": "Chengdu Shuangliu",
"status": "ok",
"observed_at": "2026-06-14T17:00:00+00:00",
"fetched_at": "2026-06-14T17:00:30+00:00",
"payload": {
"source": "amsc_awos",
"source_label": "AMSC AWOS Chengdu Shuangliu (ZUUU)",
"icao": "ZUUU",
"temp_c": 25.8,
"observation_time": "2026-06-14T17:00:00+00:00",
"observation_time_local": "2026-06-15 01:00:00",
},
}
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
monkeypatch.setattr(
city_api.legacy_routes,
"_overlay_latest_wunderground_current",
lambda city, payload: payload,
)
payload = asyncio.run(city_api._get_city_chart_data("chengdu", force_refresh=False))
assert payload["amos"]["temp_c"] == 25.8
assert payload["current"]["temp"] == 25.8
assert payload["airport_current"]["source_code"] == "amsc_awos"
def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch): def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch):
import asyncio import asyncio
+7
View File
@@ -419,6 +419,13 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
fn=_overlay_cached_runway_history_from_db, fn=_overlay_cached_runway_history_from_db,
args=(city, payload), args=(city, payload),
) )
payload = await _run_optional_city_chart_overlay(
city=city,
overlay_name="amsc_latest_raw",
payload=payload,
fn=overlay_latest_amsc_observation,
args=(legacy_routes._CACHE_DB, city, payload),
)
return await _run_optional_city_chart_overlay( return await _run_optional_city_chart_overlay(
city=city, city=city,
overlay_name="wunderground_current", overlay_name="wunderground_current",
+25 -3
View File
@@ -88,6 +88,11 @@ def _to_float(value: Any) -> Optional[float]:
return None return None
def _amsc_payload_has_observation(raw_payload: dict[str, Any]) -> bool:
temp = raw_payload.get("temp_c") if raw_payload.get("temp_c") is not None else raw_payload.get("temp")
return _to_float(temp) is not None
def _latest_amsc_row(db: Any, city: str) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]: def _latest_amsc_row(db: Any, city: str) -> tuple[Optional[dict[str, Any]], Optional[dict[str, Any]]]:
getter = getattr(db, "get_latest_raw_observation", None) getter = getattr(db, "get_latest_raw_observation", None)
if not callable(getter): if not callable(getter):
@@ -100,9 +105,26 @@ def _latest_amsc_row(db: Any, city: str) -> tuple[Optional[dict[str, Any]], Opti
if not isinstance(row, dict): if not isinstance(row, dict):
return None, None return None, None
raw_payload = row.get("payload") raw_payload = row.get("payload")
if not isinstance(raw_payload, dict) or not raw_payload: if isinstance(raw_payload, dict) and raw_payload and _amsc_payload_has_observation(raw_payload):
return row, None return row, raw_payload
return row, raw_payload
lister = getattr(db, "list_latest_raw_observations_for_city", None)
if callable(lister):
try:
candidates = lister(city, limit=20)
except Exception as exc:
logger.debug("latest AMSC raw fallback list failed city={}: {}", city, exc)
candidates = []
for candidate in candidates if isinstance(candidates, list) else []:
if not isinstance(candidate, dict):
continue
if str(candidate.get("source") or "").strip().lower() != "amsc_awos":
continue
candidate_payload = candidate.get("payload")
if isinstance(candidate_payload, dict) and _amsc_payload_has_observation(candidate_payload):
return candidate, candidate_payload
return row, None
def _raw_observation_update( def _raw_observation_update(