import fs from "node:fs"; import path from "node:path"; import { DASHBOARD_REFRESH_POLICY_MS, DASHBOARD_REFRESH_POLICY_SEC, } from "@/lib/refresh-policy"; import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client"; import { __buildChartFreshnessContextForTest, __getInitialDetailLoadDelayMsForTest, __shouldFetchCityDetailForChartForTest, __shouldPollLiveChartForTest, } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, HOURLY_CACHE_TTL_MS, __resolveCityDetailFromBatchForTest, __readHourlyCacheEntryForTest, __rememberCityDetailBatchDiagnosticsForTest, __resetHourlyDetailRequestQueueForTest, __runQueuedHourlyDetailRequestForTest, clearCityDetailCache, fetchHourlyForecastForCity, readCityDetailBatchDiagnostics, } from "@/components/dashboard/scan-terminal/temperature-chart-logic"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } async function flushMicrotasks() { for (let i = 0; i < 8; i += 1) { await Promise.resolve(); } } export async function runTests() { assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds"); 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"); assert(DASHBOARD_REFRESH_POLICY_SEC.metar === 5 * 60, "METAR polling should be 5 minutes"); assert(scanTerminalQueryPolicy.autoRefreshMs === null, "scan terminal auto refresh should be disabled after SSE patch migration"); const projectRoot = process.cwd(); const querySource = fs.readFileSync( path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"), "utf8", ); const chartSource = fs.readFileSync( path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"), "utf8", ); const chartLogicSource = fs.readFileSync( path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"), "utf8", ); const dashboardSource = fs.readFileSync( path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"), "utf8", ); assert( querySource.includes("useSsePatchVersion") && !querySource.includes("window.setInterval"), "scan list should subscribe to SSE patch state instead of running a 5-minute interval", ); assert( querySource.includes("handleForegroundScanRefresh") && querySource.includes('document.visibilityState !== "visible"') && querySource.includes("SCAN_CACHE_TTL_MS") && querySource.includes("FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS = 60_000") && querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"), "scan list should silently revalidate rows when a browser tab returns to the foreground after 60 seconds", ); assert( chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && !chartSource.includes("window.setInterval"), "selected city detail chart cache should align with 5-minute scan/metar cadence", ); assert( chartSource.includes("useLatestPatch") && chartSource.includes("latestPatch") && chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") && !chartSource.includes("2 * 60_000"), "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("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") && 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", ); assert( chartSource.includes("preloadTemperatureChartCanvas"), "terminal chart canvas should expose a preload hook for first-paint optimization", ); assert( chartSource.includes('from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"') && !chartSource.includes("next/dynamic") && !chartSource.includes("TemperatureChartCanvasFallback"), "terminal chart canvas should be loaded with the terminal route instead of showing a second-stage dynamic chunk fallback", ); assert( dashboardSource.includes("preloadTemperatureChartCanvas") && dashboardSource.includes("void preloadTemperatureChartCanvas()") && dashboardSource.includes('activeNavKey !== "thresholds"'), "terminal screen should preload the chart chunk once access is confirmed on the chart tab", ); assert( dashboardSource.includes("terminalActivationRefreshKey") && dashboardSource.includes("setTerminalActivationRefreshKey") && querySource.includes("terminalActivationRefreshKey") && querySource.includes("handleTerminalActivationRefresh") && querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"), "switching back to the terminal tab should trigger a lightweight scan refresh without waiting for browser focus events", ); assert( chartSource.includes("activationRefreshKey") && chartSource.includes("refreshActivatedCachedDetail") && chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })"), "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("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", ); assert( chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") && !chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000"), "live observation patches should update the plotted line immediately and throttle heavy probability/detail refreshes to METAR cadence", ); assert( chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") && chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") && chartLogicSource.includes("recentlyRefreshed.data"), "forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly", ); 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", ); assert( chartLogicSource.includes("cityDetailBatchQueueKey") && chartLogicSource.includes('forceRefresh ? "force" : "cached"'), "forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh", ); assert( __shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true, "compact grid slots are visible charts and should run the no-patch fallback guard", ); assert( typeof __buildChartFreshnessContextForTest === "function", "temperature charts should expose a structured freshness context for feedback diagnostics", ); const degradedFreshness = __buildChartFreshnessContextForTest( { rowAppliedAtMs: 1_000, rowObservationTime: "12:45", ssePatchAppliedAtMs: 5_000, sseObservedAt: "2026-06-10T04:48:00Z", sseRevision: 42, detailRequestedAtMs: 7_000, detailResolvedAtMs: null, detailErrorAtMs: 9_000, detailStatus: "degraded", detailSource: "partial_or_timeout", sseEmittedAtMs: 5_500, sseServerToClientLatencySec: 5, sseCollectorToClientLatencySec: 60, sseSourceToCollectorLatencySec: 64, }, 11_000, ); assert( degradedFreshness.live_path === "sse" && degradedFreshness.live_age_sec === 6 && degradedFreshness.row_applied_age_sec === 10 && degradedFreshness.sse_patch_age_sec === 6, "freshness context should make the newest live row/SSE path observable independently of detail loading", ); assert( degradedFreshness.detail_status === "degraded" && degradedFreshness.detail_source === "partial_or_timeout" && degradedFreshness.detail_request_age_sec === 4 && degradedFreshness.detail_error_age_sec === 2 && degradedFreshness.detail_age_sec === null, "freshness context should report degraded full-detail state without implying the live point is unavailable", ); assert( degradedFreshness.sse_emitted_at_ms === 5_500 && degradedFreshness.sse_server_to_client_latency_sec === 5 && degradedFreshness.sse_collector_to_client_latency_sec === 60 && degradedFreshness.sse_source_to_collector_latency_sec === 64, "freshness context should expose SSE delivery and collector latency diagnostics", ); __rememberCityDetailBatchDiagnosticsForTest("Paris", "10m", { response_source: "next_proxy_timeout", partial_reason: "proxy_timeout", city_status: { paris: { status: "proxy_timeout" } }, }); const rememberedBatchDiagnostics = readCityDetailBatchDiagnostics("paris", "10m"); assert( rememberedBatchDiagnostics?.response_source === "next_proxy_timeout" && rememberedBatchDiagnostics?.city_status?.paris?.status === "proxy_timeout", "chart logic should retain the latest detail-batch diagnostics for feedback context", ); assert( chartSource.includes("readCityDetailBatchDiagnostics") && chartSource.includes("detail_batch_diagnostics"), "temperature chart feedback context should include the latest detail-batch diagnostics", ); const rowWinsFreshness = __buildChartFreshnessContextForTest( { rowAppliedAtMs: 10_000, rowObservationTime: "12:50", ssePatchAppliedAtMs: 5_000, sseObservedAt: "2026-06-10T04:48:00Z", sseRevision: 42, sseEmittedAtMs: null, sseServerToClientLatencySec: null, sseCollectorToClientLatencySec: null, sseSourceToCollectorLatencySec: null, detailRequestedAtMs: null, detailResolvedAtMs: null, detailErrorAtMs: null, detailStatus: "idle", detailSource: "none", }, 11_000, ); assert( rowWinsFreshness.live_path === "row" && rowWinsFreshness.live_age_sec === 1, "freshness context should report the newest live path when a scan-row update is newer than the last SSE patch", ); assert( __shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false, "inactive non-compact charts should not run live polling", ); assert( chartLogicSource.includes("_hourlyRequestCache") && chartLogicSource.includes("seedHourlyForecastFromRow") && chartSource.includes("setHourly(seedHourlyForecastFromRow(getLatestRowSnapshot()))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); assert( chartLogicSource.includes("/api/cities/detail-batch") && chartLogicSource.includes("flushCityDetailBatch") && chartLogicSource.includes("primeCityDetailCache"), "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( chartLogicSource.includes('scope: "chart"') && chartLogicSource.includes("params.toString()"), "terminal chart detail batches should request the slim chart scope instead of the full city detail payload", ); assert( chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"), "visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames", ); assert( chartLogicSource.includes("partial?: boolean") && chartLogicSource.includes("missing?: string[]"), "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?\n(?:async\s+)?function fetchCityDetailBatchWithTimeout/)?.[0] || ""; assert( flushCityDetailBatchBlock.includes("partialMissingCities") && flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") && flushCityDetailBatchBlock.includes("payload?.partial === true"), "partial detail-batch misses should resolve without immediately issuing single-city fallback requests", ); assert( flushCityDetailBatchBlock.includes("if (!payload)") && flushCityDetailBatchBlock.includes("resolveAllBatchWaitersAsNull") && !flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"), "whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests", ); const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || ""; assert( fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") && !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), "first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests", ); assert( dashboardSource.includes("ONLINE_USERS_REFRESH_MS = 5 * 60_000") && dashboardSource.includes('document.visibilityState === "hidden"'), "terminal online-user presence should refresh slowly and pause while the browser tab is hidden", ); assert( dashboardSource.includes("AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000") && dashboardSource.includes("AUTH_PROFILE_RETRY_POLL_MS = 30_000") && !dashboardSource.includes("}, 5000);"), "terminal auth subscription retry should back off after the first retry instead of polling auth/me every 5 seconds", ); assert( chartSource.includes("IntersectionObserver") && chartSource.includes("shouldFetchCityDetailForChart") && chartSource.includes("isChartVisible"), "city detail prefetch should be gated to visible chart cards instead of every mounted slot", ); assert( chartSource.includes("DETAIL_LOAD_BATCH_DELAY_MS") && !chartSource.includes("slotIndex ? 300 + slotIndex * 250"), "visible chart detail loads should enter the batch queue together instead of being staggered into single-city requests", ); assert( chartSource.includes("allowStale: true") && chartCanvasSourceIncludes(chartSource, "详情暂不可用") && chartCanvasSourceIncludes(chartSource, "handleRetryDetail"), "city detail charts should show stale cache first and expose a retryable unavailable state", ); assert( chartSource.includes("TRANSIENT_DETAIL_RETRY_DELAY_MS") && chartSource.includes("scheduleTransientDetailRetry") && chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") && chartSource.includes("!retryScheduled"), "cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable", ); assert( chartSource.includes("latestRowRef") && chartSource.includes("latestRowRef.current = row"), "temperature chart should keep the latest row in a ref so live row changes do not restart detail fetches", ); const successfulHourlyDetailBlock = /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; assert( successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("getLatestRowSnapshot") && successfulHourlyDetailBlock.includes("rememberHourlyDetailSnapshot(city, targetResolution, mergedHourly)") && !/\[row\]/.test(successfulHourlyDetailBlock), "successful city detail refreshes must read the latest row, persist the merged snapshot, and avoid depending on the row object", ); assert( chartSource.includes("rememberHourlyDetailSnapshot") && chartSource.includes("const mergedHourly = mergeRowObservationIntoHourly(prev ?? rowSeed, row);") && chartSource.includes("const mergedHourly = mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(getLatestRowSnapshot()), latestPatch);"), "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] || ""; assert( coldDetailFetchBlock.length > 0 && !/^\s*row\s*,?\s*$/m.test(coldDetailFetchBlock), "cold detail fetch effect must not restart just because the live scan row object changed", ); const rawSuccessfulSetHourlyCalls = chartSource .replace(successfulHourlyDetailBlock, "") .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", ); assert( chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") && chartSource.includes("showDetailErrorBadge={showDetailErrorBadge}") && chartCanvasSourceIncludes(chartSource, "showDetailErrorBadge"), "compact grid charts should only show the stale-cache retry badge on the active slot or expanded chart", ); assert( __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true, "visible chart cards should fetch city detail", ); assert( __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false, "offscreen chart cards should not prefetch city detail", ); assert( __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false, "hidden browser tabs should not prefetch city detail", ); assert( __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 0 }) === 0 && __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 2 }) === 0, "first visible grid charts should fetch full detail immediately", ); assert( __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) > 0, "later non-active grid charts should defer full-detail loading after first paint", ); assert( __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) === __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }), "deferred grid charts should load in same-wave groups so detail-batch can coalesce them", ); assert( __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) === __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 8 }) && __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) > __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }), "later deferred grid charts should form a second batch wave instead of one request per slot", ); assert( __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: true, isMaximized: false, slotIndex: 8 }) === 0 && __getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: true, slotIndex: 8 }) === 0, "active or maximized charts should bypass deferred detail loading", ); assert( __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true, compact: true, isActive: false, isMaximized: false, slotIndex: 5, detailLoadReady: false, }) === false, "deferred grid charts should not enter the detail request queue before their delay is ready", ); assert( __shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true, compact: true, isActive: false, isMaximized: false, slotIndex: 5, detailLoadReady: true, }) === true, "deferred grid charts should fetch detail after their delay is ready", ); const normalizedBatchDetail = __resolveCityDetailFromBatchForTest( { "hong kong": { city: "hong kong", timeseries: { hourly: { times: ["00:00"], temps: [32] } }, }, } as any, "Hong Kong", ) as any; assert( normalizedBatchDetail?.city === "hong kong", "frontend detail batch lookup should accept backend-normalized city keys before falling back to single-city requests", ); __resetHourlyDetailRequestQueueForTest(); let activeRequests = 0; let maxActiveRequests = 0; let startedRequests = 0; const releases: Array<(() => void) | undefined> = []; const requestCount = MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 3; const requests = Array.from({ length: requestCount }, (_, index) => __runQueuedHourlyDetailRequestForTest( () => new Promise((resolve) => { startedRequests += 1; activeRequests += 1; maxActiveRequests = Math.max(maxActiveRequests, activeRequests); releases[index] = () => { activeRequests -= 1; resolve(index); }; }), ), ); await flushMicrotasks(); assert( startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, "city detail queue should start only the configured number of concurrent requests", ); assert( maxActiveRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, "city detail queue must cap simultaneous full-detail requests", ); releases[0]?.(); await flushMicrotasks(); assert( startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 1, "city detail queue should start the next pending request when one active request finishes", ); for (let index = 1; index < requestCount; index += 1) { await flushMicrotasks(); assert(Boolean(releases[index]), `city detail queue should eventually start queued request #${index}`); releases[index]?.(); } const results = await Promise.all(requests); assert( results.length === requestCount && results.every((value, index) => value === index), "city detail queue should resolve every queued request in order", ); __resetHourlyDetailRequestQueueForTest(); const originalWindow = (globalThis as any).window; const originalSessionStorage = (globalThis as any).sessionStorage; const originalFetch = (globalThis as any).fetch; const store = new Map(); (globalThis as any).window = {}; (globalThis as any).sessionStorage = { get length() { return store.size; }, getItem(key: string) { return store.get(key) ?? null; }, key(index: number) { return Array.from(store.keys())[index] ?? null; }, removeItem(key: string) { store.delete(key); }, setItem(key: string, value: string) { store.set(key, value); }, }; try { clearCityDetailCache(); const revalidationUrls: string[] = []; let revalidationFetches = 0; (globalThis as any).fetch = async (url: string) => { revalidationFetches += 1; revalidationUrls.push(String(url)); return { ok: true, json: async () => ({ cities: ["fallback-revalidate"], details: { "fallback-revalidate": { city: "fallback-revalidate", hourly: { times: ["00:00"], temps: [revalidationFetches], }, }, }, errors: {}, missing: [], partial: false, }), }; }; const firstDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" }); const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { bypassLocalCache: true, resolution: "10m", }); assert( firstDetail?.temps[0] === 1 && cachedDetail?.temps[0] === 1 && revalidatedDetail?.temps[0] === 2 && revalidationFetches === 2, "bypassLocalCache should revalidate cached chart detail through the network", ); assert( revalidationUrls.every((url) => url.includes("force_refresh=false")), "bypassLocalCache must not force-refresh backend observation sources", ); clearCityDetailCache(); const cacheKey = "paris:10m"; store.set( `polyweather_city_detail_v1:${cacheKey}`, JSON.stringify({ ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000, data: { forecastDaily: [], localDate: "2026-05-31", multiModelDaily: {}, probabilities: null, temps: [21], times: ["00:00"], }, }), ); assert( __readHourlyCacheEntryForTest(cacheKey) === null, "stale city detail cache should not suppress a fresh revalidation", ); assert( __readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21, "stale city detail cache should still be available for immediate chart rendering", ); clearCityDetailCache(); const runwayCacheKey = "chengdu:1m"; store.set( `polyweather_city_detail_v1:${runwayCacheKey}`, JSON.stringify({ ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000, data: { forecastDaily: [], localDate: "2026-06-15", localTime: "2026-06-15T09:00:00Z", multiModelDaily: {}, probabilities: null, temps: [27.1], times: ["09:00"], runwayPlateHistory: { "02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }], }, amos: { runway_plate_history: { "02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }], }, }, }, }), ); (globalThis as any).fetch = async () => ({ ok: true, json: async () => ({ cities: ["chengdu"], details: { chengdu: { city: "chengdu", local_date: "2026-06-15", local_time: "2026-06-15T09:04:00Z", hourly: { times: ["09:00", "09:04"], temps: [27.1, 27.8], }, models_hourly: { times: ["09:00", "09:04"], curves: { ECMWF: [27.0, 27.6] }, }, deb: { hourly_path: { source: "deb_hourly_consensus", times: ["09:00", "09:04"], temps: [30.1, 30.4], }, }, runway_plate_history: {}, amos: {}, }, }, errors: {}, missing: [], partial: false, }), }); const forceRefreshedRunwayDetail = await fetchHourlyForecastForCity("chengdu", { ignoreCache: true, resolution: "1m", }); assert( forceRefreshedRunwayDetail?.runwayPlateHistory?.["02L/20R"]?.length === 1 && __readHourlyCacheEntryForTest(runwayCacheKey, { allowStale: true })?.data?.runwayPlateHistory?.["02L/20R"]?.length === 1, "force-refresh chart detail must not overwrite cached runway history when the response omits it", ); } finally { clearCityDetailCache(); (globalThis as any).window = originalWindow; (globalThis as any).sessionStorage = originalSessionStorage; (globalThis as any).fetch = originalFetch; } } function chartCanvasSourceIncludes(source: string, pattern: string) { return source.includes(pattern) || fs.readFileSync( path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"), "utf8", ).includes(pattern); }