2026-05-25 23:03:55 +08:00
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" ;
2026-05-31 03:21:58 +08:00
import {
2026-06-10 15:34:34 +08:00
__buildChartFreshnessContextForTest ,
2026-06-01 01:27:53 +08:00
__getInitialDetailLoadDelayMsForTest ,
2026-05-31 03:21:58 +08:00
__shouldFetchCityDetailForChartForTest ,
__shouldPollLiveChartForTest ,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart" ;
2026-05-27 01:16:49 +08:00
import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS ,
2026-05-31 03:21:58 +08:00
HOURLY_CACHE_TTL_MS ,
2026-05-31 18:58:57 +08:00
__resolveCityDetailFromBatchForTest ,
2026-05-31 03:21:58 +08:00
__readHourlyCacheEntryForTest ,
2026-06-10 15:34:34 +08:00
__rememberCityDetailBatchDiagnosticsForTest ,
2026-05-27 01:16:49 +08:00
__resetHourlyDetailRequestQueueForTest ,
__runQueuedHourlyDetailRequestForTest ,
2026-05-31 03:21:58 +08:00
clearCityDetailCache ,
2026-06-13 01:34:09 +08:00
fetchHourlyForecastForCity ,
2026-06-10 15:34:34 +08:00
readCityDetailBatchDiagnostics ,
2026-05-27 01:16:49 +08:00
} from "@/components/dashboard/scan-terminal/temperature-chart-logic" ;
2026-05-25 23:03:55 +08:00
function assert ( condition : unknown , message : string ) {
if ( ! condition ) throw new Error ( message );
}
2026-05-27 01:16:49 +08:00
async function flushMicrotasks() {
for ( let i = 0 ; i < 8 ; i += 1 ) {
await Promise . resolve ();
}
}
export async function runTests() {
2026-05-25 23:03:55 +08:00
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" );
2026-05-25 23:14:48 +08:00
assert ( DASHBOARD_REFRESH_POLICY_MS . marketOverview === 10 * 60 _000 , "market overview should refresh every 10 minutes" );
2026-05-25 23:03:55 +08:00
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" );
2026-05-26 10:39:17 +08:00
assert ( scanTerminalQueryPolicy . autoRefreshMs === null , "scan terminal auto refresh should be disabled after SSE patch migration" );
2026-05-25 23:03:55 +08:00
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" ,
);
2026-05-26 23:12:48 +08:00
const chartLogicSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "scan-terminal" , "temperature-chart-logic.ts" ),
"utf8" ,
);
2026-05-31 18:28:50 +08:00
const dashboardSource = fs . readFileSync (
path . join ( projectRoot , "components" , "dashboard" , "ScanTerminalDashboard.tsx" ),
"utf8" ,
);
2026-05-25 23:03:55 +08:00
assert (
2026-05-26 10:39:17 +08:00
querySource . includes ( "useSsePatchVersion" ) &&
! querySource . includes ( "window.setInterval" ),
"scan list should subscribe to SSE patch state instead of running a 5-minute interval" ,
2026-05-25 23:03:55 +08:00
);
2026-06-13 01:34:09 +08:00
assert (
querySource . includes ( "handleForegroundScanRefresh" ) &&
querySource . includes ( 'document.visibilityState !== "visible"' ) &&
querySource . includes ( "SCAN_CACHE_TTL_MS" ) &&
querySource . includes ( "fetchScanTerminal({ forceRefresh: false, showLoading: false })" ),
"scan list should silently revalidate stale rows when a long-hidden browser tab returns to the foreground" ,
);
2026-05-25 23:03:55 +08:00
assert (
2026-05-26 23:12:48 +08:00
chartLogicSource . includes ( "DASHBOARD_REFRESH_POLICY_MS.metar" ) &&
2026-05-25 23:14:48 +08:00
! chartSource . includes ( "window.setInterval" ),
2026-05-26 08:13:37 +08:00
"selected city detail chart cache should align with 5-minute scan/metar cadence" ,
2026-05-25 23:03:55 +08:00
);
2026-05-26 08:30:33 +08:00
assert (
2026-05-26 10:39:17 +08:00
chartSource . includes ( "useLatestPatch" ) &&
chartSource . includes ( "latestPatch" ) &&
2026-06-08 13:59:38 +08:00
chartSource . includes ( "DASHBOARD_REFRESH_POLICY_MS.metar" ) &&
! chartSource . includes ( "2 * 60_000" ),
2026-06-10 23:36:00 +08:00
"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" ) &&
2026-06-13 01:34:09 +08:00
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" ,
2026-05-26 10:39:17 +08:00
);
2026-06-01 05:56:09 +08:00
assert (
2026-06-01 07:05:08 +08:00
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" ,
2026-06-01 05:56:09 +08:00
);
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" ,
);
2026-06-14 08:08:49 +08:00
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" ,
);
2026-05-26 10:39:17 +08:00
assert (
2026-05-27 08:53:36 +08:00
chartSource . includes ( "fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })" ) &&
2026-06-10 13:35:23 +08:00
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" ,
2026-05-26 10:39:17 +08:00
);
2026-06-10 12:56:43 +08:00
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" ,
);
2026-05-31 21:01:11 +08:00
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" ,
);
2026-06-03 11:41:16 +08:00
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" ,
);
2026-05-26 10:39:17 +08:00
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" ,
);
2026-06-10 15:34:34 +08:00
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" ,
);
2026-05-26 10:39:17 +08:00
assert (
__shouldPollLiveChartForTest ({ city : "shanghai" , compact : false , isActive : false , isMaximized : false }) === false ,
"inactive non-compact charts should not run live polling" ,
2026-05-26 08:30:33 +08:00
);
2026-05-26 06:13:18 +08:00
assert (
2026-05-26 23:12:48 +08:00
chartLogicSource . includes ( "_hourlyRequestCache" ) &&
chartLogicSource . includes ( "seedHourlyForecastFromRow" ) &&
2026-05-26 06:13:18 +08:00
chartSource . includes ( "setHourly(seedHourlyForecastFromRow(row))" ),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests" ,
);
2026-05-31 04:32:48 +08:00
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" ,
);
2026-06-01 11:37:16 +08:00
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" ,
);
2026-05-31 22:14:17 +08:00
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" ,
);
2026-05-31 19:51:28 +08:00
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" ,
);
2026-05-31 22:34:28 +08:00
assert (
flushCityDetailBatchBlock . includes ( "if (!payload)" ) &&
2026-06-01 12:06:46 +08:00
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" ,
2026-05-31 22:34:28 +08:00
);
2026-06-01 12:06:46 +08:00
const fetchHourlyBlock = chartLogicSource . match ( /async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/ ) ? .[ 0 ] || "" ;
2026-05-31 04:32:48 +08:00
assert (
2026-06-03 11:41:16 +08:00
fetchHourlyBlock . includes ( "queueCityDetailBatch(city, resParam, forceRefresh)" ) &&
2026-05-31 18:28:50 +08:00
! fetchHourlyBlock . includes ( "runQueuedHourlyDetailRequest" ),
2026-06-01 12:06:46 +08:00
"first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests" ,
2026-05-31 18:28:50 +08:00
);
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" ,
2026-05-31 04:32:48 +08:00
);
2026-06-08 15:12:41 +08:00
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" ,
);
2026-05-31 03:21:58 +08:00
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" ,
);
2026-05-31 21:17:38 +08:00
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" ,
);
2026-05-31 03:21:58 +08:00
assert (
chartSource . includes ( "allowStale: true" ) &&
2026-06-10 15:34:34 +08:00
chartCanvasSourceIncludes ( chartSource , "详情暂不可用" ) &&
2026-05-31 03:21:58 +08:00
chartCanvasSourceIncludes ( chartSource , "handleRetryDetail" ),
"city detail charts should show stale cache first and expose a retryable unavailable state" ,
);
2026-06-14 07:42:18 +08:00
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" ,
);
2026-06-10 00:42:07 +08:00
const successfulHourlyDetailBlock =
2026-06-10 13:35:23 +08:00
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/ . exec ( chartSource ) ? .[ 0 ] || "" ;
2026-06-10 00:42:07 +08:00
assert (
successfulHourlyDetailBlock . includes ( "setDetailError(null)" ) &&
successfulHourlyDetailBlock . includes ( "setShowingStaleDetail(false)" ) &&
2026-06-10 13:35:23 +08:00
successfulHourlyDetailBlock . includes ( "mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)" ),
2026-06-10 00:42:07 +08:00
"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" ,
);
2026-06-08 13:59:38 +08:00
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" ,
);
2026-05-31 03:21:58 +08:00
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" ,
);
2026-06-01 01:27:53 +08:00
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" ,
);
2026-06-01 02:56:13 +08:00
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" ,
);
2026-06-01 01:27:53 +08:00
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" ,
);
2026-05-31 18:58:57 +08:00
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" ,
);
2026-05-27 01:16:49 +08:00
__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 < number >(( 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 ();
2026-05-31 03:21:58 +08:00
const originalWindow = ( globalThis as any ). window ;
const originalSessionStorage = ( globalThis as any ). sessionStorage ;
2026-06-13 01:34:09 +08:00
const originalFetch = ( globalThis as any ). fetch ;
2026-05-31 03:21:58 +08:00
const store = new Map < string , string >();
( 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 {
2026-06-13 01:34:09 +08:00
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" ,
);
2026-05-31 03:21:58 +08:00
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 ;
2026-06-13 01:34:09 +08:00
( globalThis as any ). fetch = originalFetch ;
2026-05-31 03:21:58 +08:00
}
}
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 );
2026-05-25 23:03:55 +08:00
}