Files
PolyWeather/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts
2026-06-17 00:14:02 +08:00

750 lines
34 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import {
DASHBOARD_REFRESH_POLICY_MS,
DASHBOARD_REFRESH_POLICY_SEC,
} from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
__buildChartFreshnessContextForTest,
__getInitialDetailLoadDelayMsForTest,
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__resolveCityDetailFromBatchForTest,
__readHourlyCacheEntryForTest,
__rememberCityDetailBatchDiagnosticsForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
fetchFullChartDetailForCity,
readCityDetailBatchDiagnostics,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
async function flushMicrotasks() {
for (let i = 0; i < 8; i += 1) {
await Promise.resolve();
}
}
export async function runTests() {
assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds");
assert(DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback === 3 * 60_000, "chart observation fallback should poll every 180 seconds when SSE is unavailable");
assert(DASHBOARD_REFRESH_POLICY_MS.scanRows === 2 * 60_000, "region/city rows should refresh every 2 minutes");
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
assert(DASHBOARD_REFRESH_POLICY_SEC.metar === 5 * 60, "METAR polling should be 5 minutes");
assert(scanTerminalQueryPolicy.autoRefreshMs === null, "scan terminal auto refresh should be disabled after SSE patch migration");
const projectRoot = process.cwd();
const querySource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
"utf8",
);
const chartSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
const chartLogicSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"),
"utf8",
);
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
assert(
querySource.includes("useSsePatchVersion") &&
!querySource.includes("window.setInterval"),
"scan list should subscribe to SSE patch state instead of running a 5-minute interval",
);
assert(
querySource.includes("handleForegroundScanRefresh") &&
querySource.includes('document.visibilityState !== "visible"') &&
querySource.includes("SCAN_CACHE_TTL_MS") &&
querySource.includes("FOREGROUND_SCAN_REFRESH_AFTER_SUCCESS_MS = 60_000") &&
querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"),
"scan list should silently revalidate rows when a browser tab returns to the foreground after 60 seconds",
);
assert(
chartLogicSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("window.setInterval"),
"selected city detail chart cache should align with 5-minute scan/metar cadence",
);
assert(
chartSource.includes("useLatestPatch") &&
chartSource.includes("latestPatch") &&
!chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
!chartSource.includes("2 * 60_000"),
"selected city chart should consume SSE patches without coupling live observations to the model/detail refresh cadence",
);
assert(
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
chartSource.includes("refreshLiveObservation") &&
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationSnapshotIntoHourly") &&
chartLogicSource.includes("options.bypassLocalCache") &&
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
"visible charts should use the no-store observation endpoint for 180-second SSE fallback without forcing detail-batch refreshes",
);
const componentHourlyFetchCalls = chartSource.match(/fetchFullChartDetailForCity\(city,/g) || [];
const hourlyFetcherBlock =
/function useHourlyDetailFetcher\([\s\S]*?\n}\r?\n\r?\n\/\/ 岸岸 Main component/.exec(chartSource)?.[0] || "";
assert(
chartSource.includes("function useHourlyDetailFetcher") &&
componentHourlyFetchCalls.length === 1 &&
!/fetchFullChartDetailForCity\(city,[\s\S]*?\)\s*\.then\(/.test(hourlyFetcherBlock),
"temperature chart should centralize full-detail fetch lifecycle in useHourlyDetailFetcher instead of duplicating then/catch branches across effects",
);
assert(
chartSource.includes("preloadTemperatureChartCanvas"),
"terminal chart canvas should expose a preload hook for first-paint optimization",
);
assert(
chartSource.includes('from "@/components/dashboard/scan-terminal/TemperatureChartCanvas"') &&
!chartSource.includes("next/dynamic") &&
!chartSource.includes("TemperatureChartCanvasFallback"),
"terminal chart canvas should be loaded with the terminal route instead of showing a second-stage dynamic chunk fallback",
);
assert(
dashboardSource.includes("preloadTemperatureChartCanvas") &&
dashboardSource.includes("void preloadTemperatureChartCanvas()") &&
dashboardSource.includes('activeNavKey !== "thresholds"'),
"terminal screen should preload the chart chunk once access is confirmed on the chart tab",
);
assert(
dashboardSource.includes("terminalActivationRefreshKey") &&
dashboardSource.includes("setTerminalActivationRefreshKey") &&
querySource.includes("terminalActivationRefreshKey") &&
querySource.includes("handleTerminalActivationRefresh") &&
querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"),
"switching back to the terminal tab should trigger a lightweight scan refresh without waiting for browser focus events",
);
assert(
chartSource.includes("activationRefreshKey") &&
chartSource.includes("refreshActivatedCachedDetail") &&
chartSource.includes("fetchOptions: { bypassLocalCache: true }") &&
chartSource.includes("applyOptions: { updateLiveTemp: true }"),
"switching back to the terminal tab should refresh visible chart detail through cached backend data without forcing external sources",
);
assert(
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationSnapshotIntoHourly") &&
!chartSource.includes("rememberHourlyDetailSnapshot"),
"visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache",
);
assert(
!chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS") &&
!chartSource.includes("refreshProbabilityOverlayAfterPatch"),
"live observation patches must not force-refresh model, DEB, probability, or detail-batch data",
);
assert(
chartLogicSource.includes("HOURLY_FORCE_REFRESH_DEDUP_MS") &&
chartLogicSource.includes("maxAgeMs: HOURLY_FORCE_REFRESH_DEDUP_MS") &&
chartLogicSource.includes("recentlyRefreshed.data"),
"forced chart detail refreshes should reuse a very recent full-detail payload instead of refetching repeatedly",
);
assert(
chartLogicSource.includes("const SESSION_CACHE_TTL_MS = HOURLY_CACHE_TTL_MS") &&
chartLogicSource.includes("MAX_HOURLY_CACHE_ENTRIES") &&
chartLogicSource.includes("pruneHourlyCache") &&
chartLogicSource.includes("writeHourlyCacheEntry"),
"hourly detail cache should use one TTL policy and bounded memory writes instead of separate ad-hoc memory/session lifetimes",
);
assert(
chartLogicSource.includes("forceRefresh: boolean") &&
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
"ignoreCache chart detail refreshes must send force_refresh=true only for heavy model/detail resyncs, not the 180-second observation fallback",
);
assert(
chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") &&
chartLogicSource.includes('cache: "no-store"') &&
chartLogicSource.includes("mergeObservationSnapshotIntoHourly"),
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
);
assert(
chartLogicSource.includes("type ChartRenderState = {") &&
/type FullChartDetail\s*=\s*NonNullable<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) &&
/type ObservationSnapshot\s*=\s*CityObservationPayload\s*&\s*\{[\s\S]*__observationKind:\s*"observation_snapshot"/.test(chartLogicSource),
"full detail and no-store observation payloads should be separate branded types instead of sharing raw ChartRenderState",
);
assert(
chartLogicSource.includes("type HourlyCacheEntry = { ts: number; data: FullChartDetail }") &&
/function rememberHourlyDetailSnapshot\([\s\S]*data:\s*FullChartDetail/.test(chartLogicSource),
"hourly detail cache writes should require FullChartDetail so observation-only snapshots cannot be cached as model detail",
);
assert(
/async function fetchLiveObservationForCity\([\s\S]*Promise<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(
/async function fetchFullChartDetailForCity\([\s\S]*Promise<FullChartDetail \| null>/.test(chartLogicSource) &&
/type CityDetailBatchWaiter = \{[\s\S]*resolve: \(value: FullChartDetail \| null\)/.test(chartLogicSource),
"model/detail fetches and batch waiters should return FullChartDetail or null, not observation-shaped hourly state",
);
assert(
chartLogicSource.includes("cityDetailBatchQueueKey") &&
chartLogicSource.includes('forceRefresh ? "force" : "cached"'),
"forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh",
);
assert(
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
"compact grid slots are visible charts and should run the no-patch fallback guard",
);
assert(
typeof __buildChartFreshnessContextForTest === "function",
"temperature charts should expose a structured freshness context for feedback diagnostics",
);
const degradedFreshness = __buildChartFreshnessContextForTest(
{
rowAppliedAtMs: 1_000,
rowObservationTime: "12:45",
ssePatchAppliedAtMs: 5_000,
sseObservedAt: "2026-06-10T04:48:00Z",
sseRevision: 42,
detailRequestedAtMs: 7_000,
detailResolvedAtMs: null,
detailErrorAtMs: 9_000,
detailStatus: "degraded",
detailSource: "partial_or_timeout",
sseEmittedAtMs: 5_500,
sseServerToClientLatencySec: 5,
sseCollectorToClientLatencySec: 60,
sseSourceToCollectorLatencySec: 64,
},
11_000,
);
assert(
degradedFreshness.live_path === "sse" &&
degradedFreshness.live_age_sec === 6 &&
degradedFreshness.row_applied_age_sec === 10 &&
degradedFreshness.sse_patch_age_sec === 6,
"freshness context should make the newest live row/SSE path observable independently of detail loading",
);
assert(
degradedFreshness.detail_status === "degraded" &&
degradedFreshness.detail_source === "partial_or_timeout" &&
degradedFreshness.detail_request_age_sec === 4 &&
degradedFreshness.detail_error_age_sec === 2 &&
degradedFreshness.detail_age_sec === null,
"freshness context should report degraded full-detail state without implying the live point is unavailable",
);
assert(
degradedFreshness.sse_emitted_at_ms === 5_500 &&
degradedFreshness.sse_server_to_client_latency_sec === 5 &&
degradedFreshness.sse_collector_to_client_latency_sec === 60 &&
degradedFreshness.sse_source_to_collector_latency_sec === 64,
"freshness context should expose SSE delivery and collector latency diagnostics",
);
__rememberCityDetailBatchDiagnosticsForTest("Paris", "10m", {
response_source: "next_proxy_timeout",
partial_reason: "proxy_timeout",
city_status: { paris: { status: "proxy_timeout" } },
});
const rememberedBatchDiagnostics = readCityDetailBatchDiagnostics("paris", "10m");
assert(
rememberedBatchDiagnostics?.response_source === "next_proxy_timeout" &&
rememberedBatchDiagnostics?.city_status?.paris?.status === "proxy_timeout",
"chart logic should retain the latest detail-batch diagnostics for feedback context",
);
assert(
chartSource.includes("readCityDetailBatchDiagnostics") &&
chartSource.includes("detail_batch_diagnostics"),
"temperature chart feedback context should include the latest detail-batch diagnostics",
);
const rowWinsFreshness = __buildChartFreshnessContextForTest(
{
rowAppliedAtMs: 10_000,
rowObservationTime: "12:50",
ssePatchAppliedAtMs: 5_000,
sseObservedAt: "2026-06-10T04:48:00Z",
sseRevision: 42,
sseEmittedAtMs: null,
sseServerToClientLatencySec: null,
sseCollectorToClientLatencySec: null,
sseSourceToCollectorLatencySec: null,
detailRequestedAtMs: null,
detailResolvedAtMs: null,
detailErrorAtMs: null,
detailStatus: "idle",
detailSource: "none",
},
11_000,
);
assert(
rowWinsFreshness.live_path === "row" && rowWinsFreshness.live_age_sec === 1,
"freshness context should report the newest live path when a scan-row update is newer than the last SSE patch",
);
assert(
__shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false,
"inactive non-compact charts should not run live polling",
);
assert(
chartLogicSource.includes("_hourlyRequestCache") &&
chartLogicSource.includes("seedChartRenderStateFromRow") &&
!chartSource.includes("setHourly(seedChartRenderStateFromRow(getLatestRowSnapshot()))") &&
chartSource.includes("mergeRowObservationIntoHourly"),
"terminal charts should render from row data through the same merge path instead of racing a row-only skeleton against detail fetches",
);
assert(
chartLogicSource.includes("/api/cities/detail-batch") &&
chartLogicSource.includes("flushCityDetailBatch") &&
chartLogicSource.includes("primeCityDetailCache"),
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
);
assert(
chartLogicSource.includes('from "@/lib/backend-api"') &&
chartLogicSource.includes("buildBrowserBackendHeaders") &&
chartLogicSource.includes('await buildBrowserBackendHeaders({ Accept: "application/json" })') &&
chartLogicSource.includes("headers,"),
"terminal chart detail batch fetches must attach the browser Supabase bearer so users without Supabase cookies do not get backend 401 and empty charts",
);
assert(
chartLogicSource.includes('scope: "chart"') &&
chartLogicSource.includes("params.toString()"),
"terminal chart detail batches should request the slim chart scope instead of the full city detail payload",
);
assert(
chartLogicSource.includes("CITY_DETAIL_BATCH_WINDOW_MS = 100"),
"visible terminal chart detail fetches should use a wide enough batch window to coalesce cards mounted across adjacent frames",
);
assert(
chartLogicSource.includes("partial?: boolean") &&
chartLogicSource.includes("missing?: string[]"),
"frontend city detail batch payload should understand partial responses and missing city markers",
);
const flushCityDetailBatchBlock = chartLogicSource.match(/async function flushCityDetailBatch[\s\S]*?\r?\n}\r?\n\r?\n(?:async\s+)?function fetchCityDetailBatchWithTimeout/)?.[0] || "";
assert(
flushCityDetailBatchBlock.includes("partialMissingCities") &&
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") &&
flushCityDetailBatchBlock.includes("payload?.partial === true"),
"partial detail-batch misses should resolve without immediately issuing single-city fallback requests",
);
assert(
flushCityDetailBatchBlock.includes("if (!payload)") &&
flushCityDetailBatchBlock.includes("resolveAllBatchWaitersAsNull") &&
!flushCityDetailBatchBlock.includes("resolveCityDetailBatchWithSingleFallback"),
"whole-batch failures should stop at the chart batch layer instead of fanning out into single-city full-detail requests",
);
const fetchHourlyBlock = chartLogicSource.match(/async function fetchFullChartDetailForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction shouldPollLiveChart/)?.[0] || "";
assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam, forceRefresh)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
"first-paint and background city detail refreshes should both enter the batch queue without falling back to single requests",
);
assert(
dashboardSource.includes("ONLINE_USERS_REFRESH_MS = 5 * 60_000") &&
dashboardSource.includes('document.visibilityState === "hidden"'),
"terminal online-user presence should refresh slowly and pause while the browser tab is hidden",
);
assert(
dashboardSource.includes("AUTH_PROFILE_RETRY_INITIAL_DELAY_MS = 5_000") &&
dashboardSource.includes("AUTH_PROFILE_RETRY_POLL_MS = 30_000") &&
!dashboardSource.includes("}, 5000);"),
"terminal auth subscription retry should back off after the first retry instead of polling auth/me every 5 seconds",
);
assert(
chartSource.includes("IntersectionObserver") &&
chartSource.includes("shouldFetchCityDetailForChart") &&
chartSource.includes("isChartVisible"),
"city detail prefetch should be gated to visible chart cards instead of every mounted slot",
);
assert(
chartSource.includes("DETAIL_LOAD_BATCH_DELAY_MS") &&
!chartSource.includes("slotIndex ? 300 + slotIndex * 250"),
"visible chart detail loads should enter the batch queue together instead of being staggered into single-city requests",
);
assert(
chartSource.includes("allowStale: true") &&
chartCanvasSourceIncludes(chartSource, "详情暂不可用") &&
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
"city detail charts should show stale cache first and expose a retryable unavailable state",
);
assert(
chartSource.includes("TRANSIENT_DETAIL_RETRY_DELAY_MS") &&
chartSource.includes("scheduleTransientDetailRetry") &&
chartSource.includes("fetchOptions: { bypassLocalCache: true }") &&
chartSource.includes("!retryScheduled"),
"cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable",
);
assert(
chartSource.includes("latestRowRef") &&
chartSource.includes("latestRowRef.current = row"),
"temperature chart should keep the latest row in a ref so live row changes do not restart detail fetches",
);
assert(
!chartSource.includes("_hourlyCache") &&
!chartSource.includes("readSessionCache") &&
chartSource.includes("readCachedHourlyForInitialRow") &&
chartSource.includes("readHourlyDetailSnapshot") &&
chartSource.includes("detailSource: cached.source"),
"temperature chart component should not directly read global memory/session caches; cache policy belongs in temperature-chart-logic.ts",
);
const rememberSnapshotCalls = chartSource.match(/rememberHourlyDetailSnapshot\(/g) || [];
assert(
rememberSnapshotCalls.length === 0,
"temperature chart component must not persist live-merged snapshots into the full-detail cache",
);
const markDetailDegradedBlock =
/const markDetailDegraded = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || "";
assert(
markDetailDegradedBlock.includes("const detailErrorMessage") &&
markDetailDegradedBlock.includes("setDetailError(detailErrorMessage)"),
"detail degraded state and visible detail error text should be updated from the same branch so they cannot drift",
);
const successfulHourlyDetailBlock =
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/.exec(chartSource)?.[0] || "";
assert(
successfulHourlyDetailBlock.includes("setDetailError(null)") &&
successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") &&
successfulHourlyDetailBlock.includes("getLatestRowSnapshot") &&
successfulHourlyDetailBlock.includes("commitHourlySnapshot") &&
!/\[row\]/.test(successfulHourlyDetailBlock),
"successful city detail refreshes must read the latest row, merge into chart state, and avoid depending on the row object",
);
assert(
chartSource.includes("commitHourlySnapshot") &&
chartSource.includes("mergeRowObservationIntoHourly") &&
chartSource.includes("mergePatchIntoHourly"),
"live row and SSE patch merges must flow through the shared state helper without touching the full-detail cache",
);
const coldDetailFetchBlock =
/useEffect\(\(\) => \{\s*if \(!city\) \{[\s\S]*?scheduleTransientDetailRetry[\s\S]*?runHourlyDetailFetch\(\{[\s\S]*?\n \}, \[([\s\S]*?)\]\);/.exec(chartSource)?.[1] || "";
assert(
coldDetailFetchBlock.length > 0 &&
!/^\s*row\s*,?\s*$/m.test(coldDetailFetchBlock),
"cold detail fetch effect must not restart just because the live scan row object changed",
);
const rawSuccessfulSetHourlyCalls = chartSource
.replace(successfulHourlyDetailBlock, "")
.match(/setHourly\(data\);/g) || [];
assert(
rawSuccessfulSetHourlyCalls.length === 0 &&
(chartSource.match(/applySuccessfulHourlyDetail\(data/g) || []).length === 1,
"all successful city detail fetch branches should flow through the shared fetcher and success handler once",
);
assert(
chartSource.includes("const showDetailErrorBadge = !compact || isActive || isMaximized") &&
chartSource.includes("showDetailErrorBadge={showDetailErrorBadge}") &&
chartCanvasSourceIncludes(chartSource, "showDetailErrorBadge"),
"compact grid charts should only show the stale-cache retry badge on the active slot or expanded chart",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
"visible chart cards should fetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false,
"offscreen chart cards should not prefetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false,
"hidden browser tabs should not prefetch city detail",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 0 }) === 0 &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 2 }) === 0,
"first visible grid charts should fetch full detail immediately",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) > 0,
"later non-active grid charts should defer full-detail loading after first paint",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 3 }) ===
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }),
"deferred grid charts should load in same-wave groups so detail-batch can coalesce them",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) ===
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 8 }) &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 6 }) >
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: false, slotIndex: 5 }),
"later deferred grid charts should form a second batch wave instead of one request per slot",
);
assert(
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: true, isMaximized: false, slotIndex: 8 }) === 0 &&
__getInitialDetailLoadDelayMsForTest({ compact: true, isActive: false, isMaximized: true, slotIndex: 8 }) === 0,
"active or maximized charts should bypass deferred detail loading",
);
assert(
__shouldFetchCityDetailForChartForTest({
city: "paris",
documentHidden: false,
isChartVisible: true,
compact: true,
isActive: false,
isMaximized: false,
slotIndex: 5,
detailLoadReady: false,
}) === false,
"deferred grid charts should not enter the detail request queue before their delay is ready",
);
assert(
__shouldFetchCityDetailForChartForTest({
city: "paris",
documentHidden: false,
isChartVisible: true,
compact: true,
isActive: false,
isMaximized: false,
slotIndex: 5,
detailLoadReady: true,
}) === true,
"deferred grid charts should fetch detail after their delay is ready",
);
const normalizedBatchDetail = __resolveCityDetailFromBatchForTest(
{
"hong kong": {
city: "hong kong",
timeseries: { hourly: { times: ["00:00"], temps: [32] } },
},
} as any,
"Hong Kong",
) as any;
assert(
normalizedBatchDetail?.city === "hong kong",
"frontend detail batch lookup should accept backend-normalized city keys before falling back to single-city requests",
);
__resetHourlyDetailRequestQueueForTest();
let activeRequests = 0;
let maxActiveRequests = 0;
let startedRequests = 0;
const releases: Array<(() => void) | undefined> = [];
const requestCount = MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 3;
const requests = Array.from({ length: requestCount }, (_, index) =>
__runQueuedHourlyDetailRequestForTest(
() =>
new Promise<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();
const originalWindow = (globalThis as any).window;
const originalSessionStorage = (globalThis as any).sessionStorage;
const originalFetch = (globalThis as any).fetch;
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 {
clearCityDetailCache();
const revalidationUrls: string[] = [];
let revalidationFetches = 0;
(globalThis as any).fetch = async (url: string) => {
revalidationFetches += 1;
revalidationUrls.push(String(url));
return {
ok: true,
json: async () => ({
cities: ["fallback-revalidate"],
details: {
"fallback-revalidate": {
city: "fallback-revalidate",
hourly: {
times: ["00:00"],
temps: [revalidationFetches],
},
},
},
errors: {},
missing: [],
partial: false,
}),
};
};
const firstDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const cachedDetail = await fetchFullChartDetailForCity("fallback-revalidate", { resolution: "10m" });
const revalidatedDetail = await fetchFullChartDetailForCity("fallback-revalidate", {
bypassLocalCache: true,
resolution: "10m",
});
assert(
firstDetail?.temps[0] === 1 &&
cachedDetail?.temps[0] === 1 &&
revalidatedDetail?.temps[0] === 2 &&
revalidationFetches === 2,
"bypassLocalCache should revalidate cached chart detail through the network",
);
assert(
revalidationUrls.every((url) => url.includes("force_refresh=false")),
"bypassLocalCache must not force-refresh backend observation sources",
);
clearCityDetailCache();
const cacheKey = "paris:10m";
store.set(
`polyweather_city_detail_v1:${cacheKey}`,
JSON.stringify({
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
data: {
forecastDaily: [],
localDate: "2026-05-31",
multiModelDaily: {},
probabilities: null,
temps: [21],
times: ["00:00"],
},
}),
);
assert(
__readHourlyCacheEntryForTest(cacheKey) === null,
"stale city detail cache should not suppress a fresh revalidation",
);
assert(
__readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21,
"stale city detail cache should still be available for immediate chart rendering",
);
clearCityDetailCache();
const runwayCacheKey = "chengdu:1m";
store.set(
`polyweather_city_detail_v1:${runwayCacheKey}`,
JSON.stringify({
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
data: {
forecastDaily: [],
localDate: "2026-06-15",
localTime: "2026-06-15T09:00:00Z",
multiModelDaily: {},
probabilities: null,
temps: [27.1],
times: ["09:00"],
runwayPlateHistory: {
"02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }],
},
amos: {
runway_plate_history: {
"02L/20R": [{ time: "2026-06-15T09:00:00Z", temp: 27.1 }],
},
},
},
}),
);
(globalThis as any).fetch = async () => ({
ok: true,
json: async () => ({
cities: ["chengdu"],
details: {
chengdu: {
city: "chengdu",
local_date: "2026-06-15",
local_time: "2026-06-15T09:04:00Z",
hourly: {
times: ["09:00", "09:04"],
temps: [27.1, 27.8],
},
models_hourly: {
times: ["09:00", "09:04"],
curves: { ECMWF: [27.0, 27.6] },
},
deb: {
hourly_path: {
source: "deb_hourly_consensus",
times: ["09:00", "09:04"],
temps: [30.1, 30.4],
},
},
runway_plate_history: {},
amos: {},
},
},
errors: {},
missing: [],
partial: false,
}),
});
const forceRefreshedRunwayDetail = await fetchFullChartDetailForCity("chengdu", {
ignoreCache: true,
resolution: "1m",
});
assert(
forceRefreshedRunwayDetail?.runwayPlateHistory?.["02L/20R"]?.length === 1 &&
__readHourlyCacheEntryForTest(runwayCacheKey, { allowStale: true })?.data?.runwayPlateHistory?.["02L/20R"]?.length === 1,
"force-refresh chart detail must not overwrite cached runway history when the response omits it",
);
} finally {
clearCityDetailCache();
(globalThis as any).window = originalWindow;
(globalThis as any).sessionStorage = originalSessionStorage;
(globalThis as any).fetch = originalFetch;
}
}
function chartCanvasSourceIncludes(source: string, pattern: string) {
return source.includes(pattern) || fs.readFileSync(
path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8",
).includes(pattern);
}