Guard charts from stale scan rows
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user