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 { __getInitialDetailLoadDelayMsForTest, __shouldFetchCityDetailForChartForTest, __shouldPollLiveChartForTest, } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, HOURLY_CACHE_TTL_MS, __resolveCityDetailFromBatchForTest, __readHourlyCacheEntryForTest, __resetHourlyDetailRequestQueueForTest, __runQueuedHourlyDetailRequestForTest, clearCityDetailCache, } 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("2 * 60_000"), "selected city chart should consume SSE patches and use a 2-minute no-patch fallback", ); assert( chartSource.includes("loadTemperatureChartCanvasModule") && chartSource.includes("preloadTemperatureChartCanvas"), "terminal chart canvas should expose a preload hook for the dynamic chart chunk", ); 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(data)"), "visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop", ); 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( __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( __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("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("resolveCityDetailBatchWithSingleFallback") && flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)"), "single-city detail fallback should be reserved for whole-batch failures rather than successful batch misses", ); const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailWithTimeout/)?.[0] || ""; assert( fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") && !fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"), "first-paint and background city detail refreshes should both enter the batch queue before 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( 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( __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); }