From df40e6c2ca4fe3a9da2592870b140b0079fab92a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 10 Jun 2026 14:00:43 +0800 Subject: [PATCH] Guard charts from stale scan rows --- .../__tests__/temperatureChartData.test.ts | 68 +++++++++++++++++++ .../scan-terminal/temperature-chart-logic.ts | 11 +++ tests/test_web_observability.py | 40 +++++++++++ web/scan_terminal_service.py | 19 +++++- 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts index bfe65aa8..5ddc7e8d 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureChartData.test.ts @@ -334,4 +334,72 @@ export function runTests() { preservedPatchRunway?.values.some((value) => value === 28.8), "stale full-detail responses must not overwrite a newer live SSE observation point", ); + + const chengduDetail = { + forecastTodayHigh: null, + debPrediction: 31, + debQuality: null, + debHourlyPath: null, + localDate: "2026-06-10", + localTime: "13:46", + times: [], + temps: [], + modelCurves: undefined, + runwayPlateHistory: { + "02L/20R": [ + { timestamp: "13:35", temp_c: 29.6, value: 29.6 }, + { timestamp: "13:39", temp_c: 29.8, value: 29.8 }, + { timestamp: "13:43", temp_c: 30.4, value: 30.4 }, + ], + }, + runwayBandHistory: undefined, + amos: null, + current: null, + airportCurrent: { temp: 28, obs_time: "13:00", max_so_far: 28 }, + airportPrimary: { temp: 28, obs_time: "13:00", max_so_far: 28 }, + forecastDaily: [], + multiModelDaily: {}, + probabilities: null, + airportPrimaryTodayObs: [["13:00", 28]], + } as any; + const staleChengduRow = { + city: "chengdu", + local_date: "2026-06-07", + local_time: "21:37", + current_temp: 21.0, + current_max_so_far: 25.0, + temp_symbol: "°C", + tz_offset_seconds: 8 * 3600, + runway_plate_history: { + "02L/20R": [ + { time: "2026-06-07T13:20:00+00:00", temp: 21.2 }, + { time: "2026-06-07T13:30:00+00:00", temp: 21.4 }, + ], + }, + } as any; + const chengduMerged = mergeRowObservationIntoHourly(chengduDetail, staleChengduRow); + const chengduChart = buildFullDayChartData( + { + city: "chengdu", + local_date: "2026-06-10", + local_time: "13:46", + temp_symbol: "°C", + tz_offset_seconds: 8 * 3600, + } as any, + chengduMerged, + false, + ); + const chengduSettlementRunway = chengduChart.series.find((item) => item.key === "runway_02L_20R"); + assert( + chengduSettlementRunway?.values.some((value) => value === 30.4), + "current-date Chengdu detail runway history should remain visible after receiving a stale scan row", + ); + assert( + !chengduSettlementRunway?.values.some((value) => value !== null && value <= 22), + "stale previous-day Chengdu scan rows must not append a fake latest runway point to current-date detail", + ); + assert( + chengduMerged?.airportCurrent?.temp === 28, + "stale previous-day scan rows must not replace current-date detail airport conditions", + ); } diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 75b848d4..e9751407 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -1088,6 +1088,16 @@ function mergeRunwayPlateHistory( return Object.keys(result).length ? result : undefined; } +function hourlyLocalDatesConflict( + base: HourlyForecast, + live: HourlyForecast, + row: ScanOpportunityRow | null, +) { + const baseDate = String(base?.localDate || "").trim(); + const liveDate = String(live?.localDate || row?.local_date || "").trim(); + return Boolean(baseDate && liveDate && baseDate !== liveDate); +} + function rowLooksLikeRunwaySensor(row: ScanOpportunityRow | null) { const cityKey = normalizeCityKey(row?.city); const sourceText = [ @@ -1235,6 +1245,7 @@ function mergeHourlyWithLiveObservations( ): HourlyForecast { if (!base) return live; if (!live) return base; + if (hourlyLocalDatesConflict(base, live, row)) return base; const localDate = base.localDate || live.localDate || row?.local_date || null; const runwayPlateHistory = mergeRunwayPlateHistory(base.runwayPlateHistory, live.runwayPlateHistory); const amos = runwayPlateHistory diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 37167a05..bf1fb670 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -3127,6 +3127,46 @@ def test_scan_terminal_cold_requests_start_background_build_without_blocking(mon assert all("初始化" in result["stale_reason"] or "刷新中" in result["stale_reason"] for result in results) +def test_scan_terminal_nonforce_ignores_ancient_success_snapshot(monkeypatch): + filters = {"scan_mode": "tradable", "limit": 17, "min_edge_pct": 6.75} + old_success_t = 1780839484.0 + + monkeypatch.setattr( + scan_terminal_service, + "get_cached_scan_terminal_payload", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + scan_terminal_service, + "get_scan_terminal_cache_entry", + lambda *_args, **_kwargs: { + "t": old_success_t, + "success_t": old_success_t, + "success_payload": { + "generated_at": "2026-06-07T13:38:04.694350Z", + "rows": [{"id": "chengdu:2026-06-07", "city": "chengdu", "current_temp": 21.0}], + "summary": {"candidate_total": 1}, + }, + }, + ) + monkeypatch.setattr( + scan_terminal_service.time, + "time", + lambda: old_success_t + scan_terminal_service.SCAN_TERMINAL_PAYLOAD_TTL_SEC * 3, + ) + monkeypatch.setattr( + scan_terminal_service, + "_start_scan_terminal_background_refresh", + lambda *_args, **_kwargs: True, + ) + + payload = scan_terminal_service.build_scan_terminal_payload(filters) + + assert payload["status"] == "failed" + assert payload["rows"] == [] + assert payload["summary"]["candidate_total"] == 0 + + def test_scan_terminal_prewarm_builds_default_terminal_payload(monkeypatch): calls = [] diff --git a/web/scan_terminal_service.py b/web/scan_terminal_service.py index 008288b0..9420473d 100644 --- a/web/scan_terminal_service.py +++ b/web/scan_terminal_service.py @@ -43,6 +43,10 @@ from web.scan_terminal_ranker import build_ranked_scan_terminal_result _SCAN_TERMINAL_INFLIGHT_BUILD_LOCK = threading.Lock() _SCAN_TERMINAL_INFLIGHT_BUILDS: Dict[str, Future] = {} +SCAN_TERMINAL_STALE_SUCCESS_MAX_AGE_SEC = max( + SCAN_TERMINAL_PAYLOAD_TTL_SEC * 2, + 600, +) def _normalize_locale(value: Any) -> str: @@ -61,6 +65,15 @@ def _rows_count(payload: Dict[str, Any]) -> int: return len(rows) if isinstance(rows, list) else 0 +def _success_payload_within_stale_window(cached_entry: Dict[str, Any]) -> bool: + timestamp = cached_entry.get("success_t") or cached_entry.get("t") + try: + success_ts = float(timestamp) + except Exception: + return True + return (time.time() - success_ts) <= float(SCAN_TERMINAL_STALE_SUCCESS_MAX_AGE_SEC) + + def _build_stale_payload_for_timeout_if_better_cached( *, filters: Dict[str, Any], @@ -361,7 +374,11 @@ def build_scan_terminal_payload( else get_scan_terminal_cache_entry(filters) or {} ) success_payload = cached_entry.get("success_payload") - if isinstance(success_payload, dict) and success_payload: + if ( + isinstance(success_payload, dict) + and success_payload + and _success_payload_within_stale_window(cached_entry) + ): started = _start_scan_terminal_background_refresh(filters) return build_stale_scan_terminal_payload( filters=filters,