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-17 00:14:02 +08:00
fetchFullChartDetailForCity ,
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" );
2026-06-16 18:29:37 +08:00
assert ( DASHBOARD_REFRESH_POLICY_MS . liveObservationFallback === 3 * 60 _000 , "chart observation fallback should poll every 180 seconds when SSE is unavailable" );
2026-06-15 01:39:19 +08:00
assert ( DASHBOARD_REFRESH_POLICY_MS . scanRows === 2 * 60 _000 , "region/city rows should refresh every 2 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" ) &&
2026-06-15 01:39:19 +08:00
querySource . includes ( "FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS = 60_000" ) &&
2026-06-13 01:34:09 +08:00
querySource . includes ( "fetchScanTerminal({ forceRefresh: false, showLoading: false })" ),
2026-06-15 01:39:19 +08:00
"scan list should silently revalidate rows when a browser tab returns to the foreground after 60 seconds" ,
2026-06-13 01:34:09 +08:00
);
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-16 22:42:01 +08:00
! chartSource . includes ( "DASHBOARD_REFRESH_POLICY_MS.metar" ) &&
2026-06-08 13:59:38 +08:00
! chartSource . includes ( "2 * 60_000" ),
2026-06-16 22:42:01 +08:00
"selected city chart should consume SSE patches without coupling live observations to the model/detail refresh cadence" ,
2026-06-10 23:36:00 +08:00
);
assert (
2026-06-16 18:29:37 +08:00
chartSource . includes ( "LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback" ) &&
chartSource . includes ( "refreshLiveObservation" ) &&
chartSource . includes ( "fetchLiveObservationForCity" ) &&
2026-06-16 23:30:13 +08:00
chartSource . includes ( "mergeObservationSnapshotIntoHourly" ) &&
2026-06-13 01:34:09 +08:00
chartLogicSource . includes ( "options.bypassLocalCache" ) &&
chartLogicSource . includes ( "const forceRefresh = Boolean(options.ignoreCache)" ),
2026-06-16 18:29:37 +08:00
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes" ,
2026-05-26 10:39:17 +08:00
);
2026-06-17 00:14:02 +08:00
const componentHourlyFetchCalls = chartSource . match ( /fetchFullChartDetailForCity\(city,/g ) || [];
2026-06-16 18:29:37 +08:00
const hourlyFetcherBlock =
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/ . exec ( chartSource ) ? .[ 0 ] || "" ;
2026-06-16 04:46:48 +08:00
assert (
chartSource . includes ( "function useHourlyDetailFetcher" ) &&
componentHourlyFetchCalls . length === 1 &&
2026-06-17 00:14:02 +08:00
! /fetchFullChartDetailForCity\(city,[\s\S]*?\)\s*\.then\(/ . test ( hourlyFetcherBlock ),
2026-06-16 04:46:48 +08:00
"temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects" ,
);
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" ) &&
2026-06-16 04:46:48 +08:00
chartSource . includes ( "fetchOptions: { bypassLocalCache: true }" ) &&
chartSource . includes ( "applyOptions: { updateLiveTemp: true }" ),
2026-06-14 08:08:49 +08:00
"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-06-16 22:42:01 +08:00
chartSource . includes ( "fetchLiveObservationForCity" ) &&
2026-06-16 23:30:13 +08:00
chartSource . includes ( "mergeObservationSnapshotIntoHourly" ) &&
2026-06-16 22:42:01 +08:00
! chartSource . includes ( "rememberHourlyDetailSnapshot" ),
"visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache" ,
2026-05-26 10:39:17 +08:00
);
2026-06-10 12:56:43 +08:00
assert (
2026-06-16 22:42:01 +08:00
! chartSource . includes ( "PROBABILITY_REFRESH_AFTER_PATCH_MS" ) &&
! chartSource . includes ( "refreshProbabilityOverlayAfterPatch" ),
"live observation patches must not force-refresh model, DEB, probability, or detail-batch data" ,
2026-06-10 12:56:43 +08:00
);
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-16 04:57:26 +08:00
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" ,
);
2026-06-03 11:41:16 +08:00
assert (
chartLogicSource . includes ( "forceRefresh: boolean" ) &&
chartLogicSource . includes ( 'force_refresh: forceRefresh ? "true" : "false"' ),
2026-06-16 18:29:37 +08:00
"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"' ) &&
2026-06-16 23:30:13 +08:00
chartLogicSource . includes ( "mergeObservationSnapshotIntoHourly" ),
2026-06-16 18:29:37 +08:00
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail" ,
2026-06-03 11:41:16 +08:00
);
2026-06-16 23:30:13 +08:00
assert (
2026-06-17 00:14:02 +08:00
chartLogicSource . includes ( "type ChartRenderState = {" ) &&
/type FullChartDetail\s*=\s*NonNullable<ChartRenderState>\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<ChartRenderState>\s*&\s*\{[\s\S]*__detailKind:\s*"full_chart_detail"/ . test ( chartLogicSource ) &&
2026-06-16 23:30:13 +08:00
/type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/ . test ( chartLogicSource ),
2026-06-17 00:14:02 +08:00
"full detail and no-store observation payloads should be separate branded types instead of sharing raw ChartRenderState" ,
2026-06-16 23:30:13 +08:00
);
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<ObservationSnapshot \| null>/ . 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 (
2026-06-17 00:14:02 +08:00
/async function fetchFullChartDetailForCity\([\s\S]*Promise<FullChartDetail \| null>/ . test ( chartLogicSource ) &&
2026-06-16 23:30:13 +08:00
/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" ,
);
2026-06-03 11:41:16 +08:00
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" ) &&
2026-06-17 00:14:02 +08:00
chartLogicSource . includes ( "seedChartRenderStateFromRow" ) &&
! chartSource . includes ( "setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))" ) &&
2026-06-16 04:57:26 +08:00
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" ,
2026-05-26 06:13:18 +08:00
);
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-15 03:13:08 +08:00
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" ,
);
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" ,
);
2026-06-15 03:13:08 +08:00
const flushCityDetailBatchBlock = chartLogicSource . match ( /async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\n(?:async\s+)?function fetchCityDetailBatchWithTimeout/ ) ? .[ 0 ] || "" ;
2026-05-31 19:51:28 +08:00
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-17 00:14:02 +08:00
const fetchHourlyBlock = chartLogicSource . match ( /async function fetchFullChartDetailForCity[\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" ) &&
2026-06-16 04:46:48 +08:00
chartSource . includes ( "fetchOptions: { bypassLocalCache: true }" ) &&
2026-06-14 07:42:18 +08:00
chartSource . includes ( "!retryScheduled" ),
"cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable" ,
);
2026-06-15 15:21:31 +08:00
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" ,
);
2026-06-16 04:57:26 +08:00
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 (
2026-06-16 22:42:01 +08:00
rememberSnapshotCalls . length === 0 ,
"temperature chart component must not persist live-merged snapshots into the full-detail cache" ,
2026-06-16 04:57:26 +08:00
);
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" ,
);
2026-06-10 00:42:07 +08:00
const successfulHourlyDetailBlock =
2026-06-15 15:21:31 +08:00
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/ . exec ( chartSource ) ? .[ 0 ] || "" ;
2026-06-10 00:42:07 +08:00
assert (
successfulHourlyDetailBlock . includes ( "setDetailError(null)" ) &&
successfulHourlyDetailBlock . includes ( "setShowingStaleDetail(false)" ) &&
2026-06-15 15:21:31 +08:00
successfulHourlyDetailBlock . includes ( "getLatestRowSnapshot" ) &&
2026-06-16 04:57:26 +08:00
successfulHourlyDetailBlock . includes ( "commitHourlySnapshot" ) &&
2026-06-15 15:21:31 +08:00
! /\[row\]/ . test ( successfulHourlyDetailBlock ),
2026-06-16 22:42:01 +08:00
"successful city detail refreshes must read the latest row, merge into chart state, and avoid depending on the row object" ,
2026-06-15 18:47:25 +08:00
);
assert (
2026-06-16 04:57:26 +08:00
chartSource . includes ( "commitHourlySnapshot" ) &&
chartSource . includes ( "mergeRowObservationIntoHourly" ) &&
chartSource . includes ( "mergePatchIntoHourly" ),
2026-06-16 22:42:01 +08:00
"live row and SSE patch merges must flow through the shared state helper without touching the full-detail cache" ,
2026-06-15 15:21:31 +08:00
);
const coldDetailFetchBlock =
2026-06-16 04:46:48 +08:00
/useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/ . exec ( chartSource ) ? .[ 1 ] || "" ;
2026-06-15 15:21:31 +08:00
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" ,
2026-06-10 00:42:07 +08:00
);
const rawSuccessfulSetHourlyCalls = chartSource
. replace ( successfulHourlyDetailBlock , "" )
. match ( /setHourly\(data\);/g ) || [];
assert (
rawSuccessfulSetHourlyCalls . length === 0 &&
2026-06-16 04:46:48 +08:00
( chartSource . match ( /applySuccessfulHourlyDetail\(data/g ) || []). length === 1 ,
"all successful city detail fetch branches should flow through the shared fetcher and success handler once" ,
2026-06-10 00:42:07 +08:00
);
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 ,
}),
};
};
2026-06-17 00:14:02 +08:00
const firstDetail = await fetchFullChartDetailForCity ( "fallback-revalidate" , { resolution : "10m" });
const cachedDetail = await fetchFullChartDetailForCity ( "fallback-revalidate" , { resolution : "10m" });
const revalidatedDetail = await fetchFullChartDetailForCity ( "fallback-revalidate" , {
2026-06-13 01:34:09 +08:00
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" ,
);
2026-06-15 18:47:25 +08:00
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 ,
}),
});
2026-06-17 00:14:02 +08:00
const forceRefreshedRunwayDetail = await fetchFullChartDetailForCity ( "chengdu" , {
2026-06-15 18:47:25 +08:00
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" ,
);
2026-05-31 03:21:58 +08:00
} 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
}