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, fetchFullChartDetailForCity, 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.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"); 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 without coupling live observations to the model/detail refresh cadence", ); assert( chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") && chartSource.includes("refreshLiveObservation") && chartSource.includes("fetchLiveObservationForCity") && chartSource.includes("mergeObservationSnapshotIntoHourly") && chartLogicSource.includes("options.bypassLocalCache") && chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"), "visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes", ); const componentHourlyFetchCalls = chartSource.match(/fetchFullChartDetailForCity\(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 && !/fetchFullChartDetailForCity\(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( 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("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("fetchLiveObservationForCity") && chartSource.includes("mergeObservationSnapshotIntoHourly") && !chartSource.includes("rememberHourlyDetailSnapshot"), "visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache", ); assert( !chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") && !chartSource.includes("refreshProbabilityOverlayAfterPatch"), "live observation patches must not force-refresh model, DEB, probability, or detail-batch data", ); 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("const SESSION_CACHE_TTL_MS = HOURLY_CACHE_TTL_MS") && chartLogicSource.includes("MAX_HOURLY_CACHE_ENTRIES") && chartLogicSource.includes("pruneHourlyCache") && chartLogicSource.includes("writeHourlyCacheEntry"), "hourly detail cache should use one TTL policy and bounded memory writes instead of separate ad-hoc memory/session lifetimes", ); assert( chartLogicSource.includes("forceRefresh: boolean") && chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'), "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("mergeObservationSnapshotIntoHourly"), "live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail", ); assert( chartLogicSource.includes("type ChartRenderState = {") && /type FullChartDetail\s*=\s*NonNullable\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && !chartLogicSource.includes("HourlyForecast") && !chartSource.includes("HourlyForecast"), "chart render state should be named ChartRenderState; the historical HourlyForecast name must not appear in chart data APIs", ); assert( /type FullChartDetail\s*=\s*NonNullable\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/.test(chartLogicSource) && /type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource), "full detail and no-store observation payloads should be separate branded types instead of sharing raw ChartRenderState", ); assert( chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") && /function rememberHourlyDetailSnapshot\([\s\S]*data:\s*FullChartDetail/.test(chartLogicSource), "hourly detail cache writes should require FullChartDetail so observation-only snapshots cannot be cached as model detail", ); assert( /async function fetchLiveObservationForCity\([\s\S]*Promise/.test(chartLogicSource) && chartLogicSource.includes("function observationPayloadToSnapshot") && chartLogicSource.includes("function mergeObservationSnapshotIntoHourly"), "live observation fetches should return ObservationSnapshot and enter chart state through the observation snapshot merge path", ); assert( /async function fetchFullChartDetailForCity\([\s\S]*Promise/.test(chartLogicSource) && /type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource), "model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state", ); 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("seedChartRenderStateFromRow") && !chartSource.includes("setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))") && chartSource.includes("mergeRowObservationIntoHourly"), "terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches", ); 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 fetchFullChartDetailForCity[\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("fetchOptions: { bypassLocalCache: true }") && 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", ); assert( !chartSource.includes("_hourlyCache") && !chartSource.includes("readSessionCache") && chartSource.includes("readCachedHourlyForInitialRow") && chartSource.includes("readHourlyDetailSnapshot") && chartSource.includes("detailSource: cached.source"), "temperature chart component should not directly read global memory/session caches; cache policy belongs in temperature-chart-logic.ts", ); const rememberSnapshotCalls = chartSource.match(/rememberHourlyDetailSnapshot\(/g) || []; assert( rememberSnapshotCalls.length === 0, "temperature chart component must not persist live-merged snapshots into the full-detail cache", ); const markDetailDegradedBlock = /const markDetailDegraded = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || ""; assert( markDetailDegradedBlock.includes("const detailErrorMessage") && markDetailDegradedBlock.includes("setDetailError(detailErrorMessage)"), "detail degraded state and visible detail error text should be updated from the same branch so they cannot drift", ); 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("commitHourlySnapshot") && !/\[row\]/.test(successfulHourlyDetailBlock), "successful city detail refreshes must read the latest row, merge into chart state, and avoid depending on the row object", ); assert( chartSource.includes("commitHourlySnapshot") && chartSource.includes("mergeRowObservationIntoHourly") && chartSource.includes("mergePatchIntoHourly"), "live row and SSE patch merges must flow through the shared state helper without touching the full-detail cache", ); const coldDetailFetchBlock = /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), "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 === 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") && 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 fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" }); const cachedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" }); const revalidatedDetail = await fetchFullChartDetailForCity("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 fetchFullChartDetailForCity("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); }