拆分图表实时观测兜底

This commit is contained in:
2569718930@qq.com
2026-06-16 18:29:37 +08:00
parent f1d9487017
commit 135198e161
10 changed files with 882 additions and 25 deletions
@@ -36,6 +36,7 @@ async function flushMicrotasks() {
export async function runTests() {
assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds");
assert(DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback === 3 * 60_000, "chart observation fallback should poll every 180 seconds when SSE is unavailable");
assert(DASHBOARD_REFRESH_POLICY_MS.scanRows === 2 * 60_000, "region/city rows should refresh every 2 minutes");
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
@@ -86,19 +87,21 @@ export async function runTests() {
"selected city chart should consume SSE patches and keep METAR cadence for heavy probability refreshes instead of a 2-minute forced refresh",
);
assert(
chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") &&
chartSource.includes("refreshCachedDetail") &&
chartSource.includes("runHourlyDetailFetch") &&
chartSource.includes("fetchOptions: { bypassLocalCache: true }") &&
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
chartSource.includes("refreshLiveObservation") &&
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") &&
chartLogicSource.includes("options.bypassLocalCache") &&
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
"visible charts should bypass the five-minute browser detail cache every observation cadence without force-refreshing backend sources",
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes",
);
const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || [];
const hourlyFetcherBlock =
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || "";
assert(
chartSource.includes("function useHourlyDetailFetcher") &&
componentHourlyFetchCalls.length === 1 &&
!/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(chartSource),
!/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock),
"temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects",
);
assert(
@@ -159,7 +162,13 @@ export async function runTests() {
assert(
chartLogicSource.includes("forceRefresh: boolean") &&
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
"ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches",
"ignoreCache chart detail refreshes must send force_refresh=true only for heavy model/detail resyncs, not the 180-second observation fallback",
);
assert(
chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") &&
chartLogicSource.includes('cache: "no-store"') &&
chartLogicSource.includes("mergeObservationPayloadIntoHourly"),
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
);
assert(
chartLogicSource.includes("cityDetailBatchQueueKey") &&
@@ -197,8 +197,8 @@ export function runTests() {
"temperature chart must keep METAR cadence for heavy patch-triggered probability refreshes",
);
assert(
chart.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation"),
"temperature chart must use observation cadence for lightweight cached no-patch refreshes",
chart.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback"),
"temperature chart must use the 180-second observation fallback cadence when SSE patches stop",
);
assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas");
assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars");
@@ -229,13 +229,15 @@ export function runTests() {
chart.includes("ignoreCache: true") && chart.includes("currentCityLocalDate !== loadedLocalDate"),
"temperature chart must background-refresh full city detail when the city-local day rolls over",
);
const fallbackRefreshBlock = chart.match(/const refreshCachedDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert(
fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
fallbackRefreshBlock.includes("fetchOptions: { bypassLocalCache: true }") &&
fallbackRefreshBlock.includes("fetchLiveObservationForCity") &&
fallbackRefreshBlock.includes("applyLiveObservationPayload") &&
chart.includes("mergeObservationPayloadIntoHourly") &&
!fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
!fallbackRefreshBlock.includes("ignoreCache: true") &&
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
"no-patch fallback refresh should revalidate through cached backend detail without force-refreshing sources or showing the loading overlay",
"no-patch fallback refresh should merge no-store observation data without refreshing cached detail-batch or showing the loading overlay",
);
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, runHourlyDetailFetch\]\);/)?.[0] || "";
assert(
@@ -3,6 +3,7 @@ import type { CityDetail } from "@/lib/dashboard-types";
import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import {
buildFullDayChartData,
mergeObservationPayloadIntoHourly,
mergeHourlyWithLiveObservations,
mergePatchIntoHourly,
mergeRowObservationIntoHourly,
@@ -835,6 +836,53 @@ export function runTests() {
"instant-restore cache must include live-merged runway history so returning to terminal shows it immediately",
);
const observationOnlyPayload = {
city: "chengdu",
local_date: "2026-06-15",
local_time: "17:45",
airport_current: {
temp: 27.1,
obs_time: "2026-06-15T17:45:00+08:00",
source_code: "amsc_awos",
source_label: "AMSC AWOS",
},
airport_primary: {
temp: 27.1,
obs_time: "2026-06-15T17:45:00+08:00",
source_code: "amsc_awos",
source_label: "AMSC AWOS",
},
metar_today_obs: [
{
time: "17:45",
temp: 27.1,
obs_time: "2026-06-15T17:45:00+08:00",
source_code: "amsc_awos",
},
],
runway_plate_history: {
"02L/20R": [{ time: "2026-06-15T17:45:00+08:00", tdz_temp: 27.1, end_temp: 26.8 }],
},
};
const observationMergedChengdu = mergeObservationPayloadIntoHourly(
cachedChengduDetail,
observationOnlyPayload as any,
);
assert(
observationMergedChengdu?.airportCurrent?.temp === 27.1 &&
observationMergedChengdu?.airportPrimary?.source_code === "amsc_awos",
"observation endpoint payload should update the current observation block",
);
assert(
observationMergedChengdu?.modelCurves?.ECMWF?.length === 2 &&
observationMergedChengdu?.debHourlyPath?.temps?.includes(30.9),
"observation endpoint payload must not clear DEB or multi-model chart detail",
);
assert(
(observationMergedChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2,
"observation endpoint payload should append fresh runway history onto cached detail history",
);
_hourlyCache.clear();
for (let i = 0; i < 180; i += 1) {
const row = {