750 lines
34 KiB
TypeScript
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);
|
|
}
|