From fbea6df175ff9bf0d5166e9ec293bc9205ef1b52 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 04:46:48 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E7=BB=88=E7=AB=AF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E5=88=B7=E6=96=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiveTemperatureThresholdChart.tsx | 297 +++++++++--------- .../__tests__/refreshCadencePolicy.test.ts | 23 +- .../__tests__/ssePatchArchitecture.test.ts | 15 +- 3 files changed, 176 insertions(+), 159 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index a45af3e1..e1109106 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -554,6 +554,84 @@ function readCachedHourlyForInitialRow( return null; } +type HourlyDetailFetchOptions = { + ignoreCache?: boolean; + bypassLocalCache?: boolean; +}; + +type HourlyDetailFetchRequest = { + source: ChartDetailSource; + fetchOptions?: HourlyDetailFetchOptions; + applyOptions?: { updateLiveTemp?: boolean }; + showUserError?: boolean; + isCancelled?: () => boolean; + onEmpty?: () => void; + onError?: () => void; + onSettled?: () => void; +}; + +function useHourlyDetailFetcher({ + city, + targetResolution, + markDetailRequest, + markDetailDegraded, + applySuccessfulHourlyDetail, +}: { + city: string; + targetResolution: string; + markDetailRequest: (source: ChartDetailSource) => void; + markDetailDegraded: (options?: { showUserError?: boolean }) => void; + applySuccessfulHourlyDetail: (data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => void; +}) { + return useCallback( + async ({ + source, + fetchOptions = {}, + applyOptions, + showUserError = false, + isCancelled, + onEmpty, + onError, + onSettled, + }: HourlyDetailFetchRequest) => { + const cancelled = () => Boolean(isCancelled?.()); + if (!city || cancelled()) return null; + + markDetailRequest(source); + try { + const data = await fetchHourlyForecastForCity(city, { + ...fetchOptions, + resolution: targetResolution, + }); + if (cancelled()) return null; + if (!data) { + if (onEmpty) { + onEmpty(); + } else { + markDetailDegraded({ showUserError }); + } + return null; + } + + applySuccessfulHourlyDetail(data, applyOptions); + return data; + } catch { + if (!cancelled()) { + if (onError) { + onError(); + } else { + markDetailDegraded({ showUserError }); + } + } + return null; + } finally { + if (!cancelled()) onSettled?.(); + } + }, + [city, targetResolution, markDetailRequest, markDetailDegraded, applySuccessfulHourlyDetail], + ); +} + // ── Main component ───────────────────────────────────────────────────── export function LiveTemperatureThresholdChart({ @@ -763,6 +841,14 @@ export function LiveTemperatureThresholdChart({ })); }, [city, targetResolution, getLatestRowSnapshot]); + const runHourlyDetailFetch = useHourlyDetailFetcher({ + city, + targetResolution, + markDetailRequest, + markDetailDegraded, + applySuccessfulHourlyDetail, + }); + useEffect(() => { if (!city || !currentRowObservationSignature) return; if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return; @@ -843,7 +929,6 @@ export function LiveTemperatureThresholdChart({ setShowingStaleDetail(false); } setIsHourlyLoading(true); - markDetailRequest("network"); let cancelled = false; let retryScheduled = false; let retryTimer: ReturnType | null = null; @@ -853,45 +938,26 @@ export function LiveTemperatureThresholdChart({ markDetailDegraded(); retryTimer = setTimeout(() => { if (cancelled) return; - markDetailRequest("network"); - fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded({ showUserError: true }); - return; - } - applySuccessfulHourlyDetail(data); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded({ showUserError: true }); - } - }) - .finally(() => { - if (!cancelled) setIsHourlyLoading(false); - }); + void runHourlyDetailFetch({ + source: "network", + fetchOptions: { bypassLocalCache: true }, + showUserError: true, + isCancelled: () => cancelled, + onSettled: () => setIsHourlyLoading(false), + }); }, TRANSIENT_DETAIL_RETRY_DELAY_MS); }; const timer = setTimeout(() => { - fetchHourlyForecastForCity(city, { resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - scheduleTransientDetailRetry(); - return; - } - applySuccessfulHourlyDetail(data); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded({ showUserError: true }); - } - }) - .finally(() => { - if (!cancelled && !retryScheduled) setIsHourlyLoading(false); - }); + void runHourlyDetailFetch({ + source: "network", + showUserError: true, + isCancelled: () => cancelled, + onEmpty: scheduleTransientDetailRetry, + onSettled: () => { + if (!retryScheduled) setIsHourlyLoading(false); + }, + }); }, DETAIL_LOAD_BATCH_DELAY_MS); return () => { @@ -911,8 +977,7 @@ export function LiveTemperatureThresholdChart({ detailRetryNonce, getLatestRowSnapshot, markDetailDegraded, - markDetailRequest, - applySuccessfulHourlyDetail, + runHourlyDetailFetch, ]); useEffect(() => { @@ -950,54 +1015,34 @@ export function LiveTemperatureThresholdChart({ let cancelled = false; const refreshProbabilityOverlayAfterPatch = () => { - markDetailRequest("force_refresh"); - fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded(); - } - }); + void runHourlyDetailFetch({ + source: "force_refresh", + fetchOptions: { ignoreCache: true }, + isCancelled: () => cancelled, + }); }; refreshProbabilityOverlayAfterPatch(); return () => { cancelled = true; }; - }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [latestPatch, city, targetResolution, compact, isActive, isMaximized, getLatestRowSnapshot, runHourlyDetailFetch]); useEffect(() => { if (!resyncVersion || !city) return; let cancelled = false; - markDetailRequest("force_refresh"); - fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded(); - } - }) - .finally(() => { - if (!cancelled) setIsHourlyLoading(false); - }); + void runHourlyDetailFetch({ + source: "force_refresh", + fetchOptions: { ignoreCache: true }, + isCancelled: () => cancelled, + onSettled: () => { + setIsHourlyLoading(false); + }, + }); return () => { cancelled = true; }; - }, [resyncVersion, city, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [resyncVersion, city, runHourlyDetailFetch]); // ── SSE fallback: visible charts refresh cached detail at observation cadence if patches stop. ── useEffect(() => { @@ -1007,25 +1052,14 @@ export function LiveTemperatureThresholdChart({ const refreshCachedDetail = () => { const now = Date.now(); lastPatchAtRef.current = now; - markDetailRequest("network"); - fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data, { updateLiveTemp: true }); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded(); - } - }) - .finally(() => { - if (!cancelled) setIsHourlyLoading(false); - }); + void runHourlyDetailFetch({ + source: "network", + fetchOptions: { bypassLocalCache: true }, + applyOptions: { updateLiveTemp: true }, + isCancelled: () => cancelled, + onSettled: () => setIsHourlyLoading(false), + }); }; const checkFallback = () => { @@ -1040,7 +1074,7 @@ export function LiveTemperatureThresholdChart({ cancelled = true; clearInterval(id); }; - }, [city, compact, isActive, isMaximized, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [city, compact, isActive, isMaximized, targetResolution, runHourlyDetailFetch]); useEffect(() => { if (!activationRefreshKey) return; @@ -1055,25 +1089,14 @@ export function LiveTemperatureThresholdChart({ lastForegroundRefreshAtRef.current = now; lastPatchAtRef.current = now; - markDetailRequest("network"); - fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data, { updateLiveTemp: true }); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded(); - } - }) - .finally(() => { - if (!cancelled) setIsHourlyLoading(false); - }); + void runHourlyDetailFetch({ + source: "network", + fetchOptions: { bypassLocalCache: true }, + applyOptions: { updateLiveTemp: true }, + isCancelled: () => cancelled, + onSettled: () => setIsHourlyLoading(false), + }); }; refreshActivatedCachedDetail(); @@ -1087,9 +1110,7 @@ export function LiveTemperatureThresholdChart({ isActive, isMaximized, targetResolution, - markDetailDegraded, - markDetailRequest, - applySuccessfulHourlyDetail, + runHourlyDetailFetch, ]); useEffect(() => { @@ -1110,22 +1131,13 @@ export function LiveTemperatureThresholdChart({ lastForegroundRefreshAtRef.current = now; lastPatchAtRef.current = now; - markDetailRequest("network"); - fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data, { updateLiveTemp: true }); - }) - .catch(() => { - if (!cancelled) { - markDetailDegraded(); - } - }); + void runHourlyDetailFetch({ + source: "network", + fetchOptions: { bypassLocalCache: true }, + applyOptions: { updateLiveTemp: true }, + isCancelled: () => cancelled, + }); }; const handleVisibilityChange = () => { @@ -1140,7 +1152,7 @@ export function LiveTemperatureThresholdChart({ document.removeEventListener("visibilitychange", handleVisibilityChange); window.removeEventListener("focus", refreshForegroundFullDetail); }; - }, [city, compact, isActive, isMaximized, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [city, compact, isActive, isMaximized, targetResolution, runHourlyDetailFetch]); useEffect(() => { if (!city || !currentCityLocalDate) return; @@ -1150,27 +1162,20 @@ export function LiveTemperatureThresholdChart({ localDayRolloverFetchDateRef.current = currentCityLocalDate; let cancelled = false; - markDetailRequest("force_refresh"); - fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution }) - .then((data) => { - if (cancelled) return; - if (!data) { - markDetailDegraded(); - return; - } - applySuccessfulHourlyDetail(data); - }) - .catch(() => { - if (!cancelled) { - localDayRolloverFetchDateRef.current = ""; - markDetailDegraded(); - } - }); + void runHourlyDetailFetch({ + source: "force_refresh", + fetchOptions: { ignoreCache: true }, + isCancelled: () => cancelled, + onError: () => { + localDayRolloverFetchDateRef.current = ""; + markDetailDegraded(); + }, + }); return () => { cancelled = true; }; - }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]); + }, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, runHourlyDetailFetch]); const chartHourly = useMemo(() => { if (!hourly) return hourly; diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 2783d212..6d419ca9 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -88,11 +88,19 @@ export async function runTests() { assert( chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") && chartSource.includes("refreshCachedDetail") && - chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") && + chartSource.includes("runHourlyDetailFetch") && + chartSource.includes("fetchOptions: { bypassLocalCache: true }") && 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", ); + const componentHourlyFetchCalls = chartSource.match(/fetchHourlyForecastForCity\(city,/g) || []; + assert( + chartSource.includes("function useHourlyDetailFetcher") && + componentHourlyFetchCalls.length === 1 && + !/fetchHourlyForecastForCity\(city,[\s\S]*?\)\s*\.then\(/.test(chartSource), + "temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects", + ); assert( chartSource.includes("preloadTemperatureChartCanvas"), "terminal chart canvas should expose a preload hook for first-paint optimization", @@ -120,11 +128,12 @@ export async function runTests() { assert( chartSource.includes("activationRefreshKey") && chartSource.includes("refreshActivatedCachedDetail") && - chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })"), + chartSource.includes("fetchOptions: { bypassLocalCache: true }") && + chartSource.includes("applyOptions: { updateLiveTemp: true }"), "switching back to the terminal tab should refresh visible chart detail through cached backend data without forcing external sources", ); assert( - chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && + chartSource.includes("fetchOptions: { ignoreCache: true }") && chartSource.includes("const mergedHourly = mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, latestRow)") && chartSource.includes("return mergedHourly;"), "visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations", @@ -325,7 +334,7 @@ export async function runTests() { assert( chartSource.includes("TRANSIENT_DETAIL_RETRY_DELAY_MS") && chartSource.includes("scheduleTransientDetailRetry") && - chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") && + chartSource.includes("fetchOptions: { bypassLocalCache: true }") && chartSource.includes("!retryScheduled"), "cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable", ); @@ -351,7 +360,7 @@ export async function runTests() { "live row and SSE patch merges must persist their hourly snapshots so returning to terminal restores runway history immediately", ); const coldDetailFetchBlock = - /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?fetchHourlyForecastForCity\(city, \{ resolution: targetResolution \}\)[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; + /useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || ""; assert( coldDetailFetchBlock.length > 0 && !/^\s*row\s*,?\s*$/m.test(coldDetailFetchBlock), @@ -362,8 +371,8 @@ export async function runTests() { .match(/setHourly\(data\);/g) || []; assert( rawSuccessfulSetHourlyCalls.length === 0 && - (chartSource.match(/applySuccessfulHourlyDetail\(data/g) || []).length >= 5, - "all successful city detail fetch branches should use the shared success handler", + (chartSource.match(/applySuccessfulHourlyDetail\(data/g) || []).length === 1, + "all successful city detail fetch branches should flow through the shared fetcher and success handler once", ); assert( chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") && diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 7a7731b9..517df2cb 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -231,14 +231,17 @@ export function runTests() { ); const fallbackRefreshBlock = chart.match(/const refreshCachedDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; assert( - fallbackRefreshBlock.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") && + fallbackRefreshBlock.includes("runHourlyDetailFetch") && + fallbackRefreshBlock.includes("fetchOptions: { bypassLocalCache: true }") && !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", ); - const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, targetResolution, applySuccessfulHourlyDetail\]\);/)?.[0] || ""; + const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, runHourlyDetailFetch\]\);/)?.[0] || ""; assert( - !resyncBlock.includes("setIsHourlyLoading(true)"), + resyncBlock.includes("runHourlyDetailFetch") && + resyncBlock.includes("fetchOptions: { ignoreCache: true }") && + !resyncBlock.includes("setIsHourlyLoading(true)"), "SSE replay resync should refresh full detail in the background without showing the loading overlay", ); assert( @@ -250,7 +253,7 @@ export function runTests() { const foregroundRefreshBlock = chart.match(/const refreshForegroundFullDetail = \(\) => \{[\s\S]*?\n \};/)?.[0] || ""; assert( foregroundRefreshBlock.includes("bypassLocalCache: true") && - foregroundRefreshBlock.includes("fetchHourlyForecastForCity") && + foregroundRefreshBlock.includes("runHourlyDetailFetch") && foregroundRefreshBlock.includes("FOREGROUND_FULL_DETAIL_REFRESH_DEDUP_MS") && !foregroundRefreshBlock.includes("ignoreCache: true") && !foregroundRefreshBlock.includes("setIsHourlyLoading(true)"), @@ -291,11 +294,11 @@ export function runTests() { "temperature chart must trigger a throttled background probability refresh after live observation patches", ); const patchEffectBlock = chart.match( - /useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?refreshProbabilityOverlayAfterPatch\(\);[\s\S]*?\}, \[[^\]]*latestPatch[^\]]*applySuccessfulHourlyDetail[^\]]*\]\);/, + /useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?refreshProbabilityOverlayAfterPatch\(\);[\s\S]*?\}, \[[^\]]*latestPatch[^\]]*runHourlyDetailFetch[^\]]*\]\);/, )?.[0] || ""; assert( patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") && - patchEffectBlock.includes("ignoreCache: true") && + patchEffectBlock.includes("fetchOptions: { ignoreCache: true }") && !patchEffectBlock.includes("setIsHourlyLoading(true)"), "live patch probability refresh must recompute legacy Gaussian in the background without showing a loading overlay", );