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, 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 === 5 * 60_000, "region/city rows should refresh every 5 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( 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 use a METAR-cadence no-patch fallback instead of a 2-minute forced refresh", ); 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( chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") && chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row))"), "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(row))"), "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('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?\nfunction 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", ); const successfulHourlyDetailBlock = /const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || ""; assert( successfulHourlyDetailBlock.includes("setDetailError(null)") && successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") && successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"), "successful city detail refreshes must clear stale-cache retry state when fresh detail arrives", ); 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 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 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", ); } finally { clearCityDetailCache(); (globalThis as any).window = originalWindow; (globalThis as any).sessionStorage = originalSessionStorage; } } 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); }