579 lines
25 KiB
TypeScript
579 lines
25 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,
|
|
fetchHourlyForecastForCity,
|
|
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.scanRows === 5 * 60_000, "region/city rows should refresh every 5 minutes");
|
|
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
|
|
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
|
|
assert(DASHBOARD_REFRESH_POLICY_SEC.metar === 5 * 60, "METAR polling should be 5 minutes");
|
|
assert(scanTerminalQueryPolicy.autoRefreshMs === null, "scan terminal auto refresh should be disabled after SSE patch migration");
|
|
|
|
const projectRoot = process.cwd();
|
|
const querySource = fs.readFileSync(
|
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
|
|
"utf8",
|
|
);
|
|
const chartSource = fs.readFileSync(
|
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
|
"utf8",
|
|
);
|
|
const chartLogicSource = fs.readFileSync(
|
|
path.join(projectRoot, "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"),
|
|
"utf8",
|
|
);
|
|
const dashboardSource = fs.readFileSync(
|
|
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
|
"utf8",
|
|
);
|
|
|
|
assert(
|
|
querySource.includes("useSsePatchVersion") &&
|
|
!querySource.includes("window.setInterval"),
|
|
"scan list should subscribe to SSE patch state instead of running a 5-minute interval",
|
|
);
|
|
assert(
|
|
querySource.includes("handleForegroundScanRefresh") &&
|
|
querySource.includes('document.visibilityState !== "visible"') &&
|
|
querySource.includes("SCAN_CACHE_TTL_MS") &&
|
|
querySource.includes("fetchScanTerminal({ forceRefresh: false, showLoading: false })"),
|
|
"scan list should silently revalidate stale rows when a long-hidden browser tab returns to the foreground",
|
|
);
|
|
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 and keep METAR cadence for heavy probability refreshes instead of a 2-minute forced refresh",
|
|
);
|
|
assert(
|
|
chartSource.includes("NO_PATCH_CACHED_DETAIL_REFRESH_MS = DASHBOARD_REFRESH_POLICY_MS.observation") &&
|
|
chartSource.includes("refreshCachedDetail") &&
|
|
chartSource.includes("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") &&
|
|
chartLogicSource.includes("options.bypassLocalCache") &&
|
|
chartLogicSource.includes("const forceRefresh = Boolean(options.ignoreCache)"),
|
|
"visible charts should bypass the five-minute browser detail cache every observation cadence without force-refreshing backend sources",
|
|
);
|
|
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("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })"),
|
|
"switching back to the terminal tab should refresh visible chart detail through cached backend data without forcing external sources",
|
|
);
|
|
assert(
|
|
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
|
|
chartSource.includes("setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row))"),
|
|
"visible chart fallback must refresh full city detail at the current chart resolution while preserving newer live observations",
|
|
);
|
|
assert(
|
|
chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
|
!chartSource.includes("PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000"),
|
|
"live observation patches should update the plotted line immediately and throttle heavy probability/detail refreshes to METAR cadence",
|
|
);
|
|
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("forceRefresh: boolean") &&
|
|
chartLogicSource.includes('force_refresh: forceRefresh ? "true" : "false"'),
|
|
"ignoreCache chart detail refreshes must send force_refresh=true so fresh METAR observations bypass proxy and backend chart caches",
|
|
);
|
|
assert(
|
|
chartLogicSource.includes("cityDetailBatchQueueKey") &&
|
|
chartLogicSource.includes('forceRefresh ? "force" : "cached"'),
|
|
"forced and cached chart detail requests must use separate batch queues so a normal first-paint request cannot downgrade a live refresh",
|
|
);
|
|
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("seedHourlyForecastFromRow") &&
|
|
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
|
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
|
);
|
|
assert(
|
|
chartLogicSource.includes("/api/cities/detail-batch") &&
|
|
chartLogicSource.includes("flushCityDetailBatch") &&
|
|
chartLogicSource.includes("primeCityDetailCache"),
|
|
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
|
|
);
|
|
assert(
|
|
chartLogicSource.includes('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?\nfunction fetchCityDetailBatchWithTimeout/)?.[0] || "";
|
|
assert(
|
|
flushCityDetailBatchBlock.includes("partialMissingCities") &&
|
|
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)") &&
|
|
flushCityDetailBatchBlock.includes("payload?.partial === true"),
|
|
"partial detail-batch misses should resolve without immediately issuing single-city fallback requests",
|
|
);
|
|
assert(
|
|
flushCityDetailBatchBlock.includes("if (!payload)") &&
|
|
flushCityDetailBatchBlock.includes("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 fetchHourlyForecastForCity[\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("fetchHourlyForecastForCity(city, { bypassLocalCache: true, resolution: targetResolution })") &&
|
|
chartSource.includes("!retryScheduled"),
|
|
"cold partial detail-batch misses should stay in loading state and retry cached detail once before showing unavailable",
|
|
);
|
|
const successfulHourlyDetailBlock =
|
|
/const applySuccessfulHourlyDetail = useCallback\([\s\S]*?\n \}, \[row\]\);/.exec(chartSource)?.[0] || "";
|
|
assert(
|
|
successfulHourlyDetailBlock.includes("setDetailError(null)") &&
|
|
successfulHourlyDetailBlock.includes("setShowingStaleDetail(false)") &&
|
|
successfulHourlyDetailBlock.includes("mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row)"),
|
|
"successful city detail refreshes must clear stale-cache retry state when fresh detail arrives",
|
|
);
|
|
const rawSuccessfulSetHourlyCalls = chartSource
|
|
.replace(successfulHourlyDetailBlock, "")
|
|
.match(/setHourly\(data\);/g) || [];
|
|
assert(
|
|
rawSuccessfulSetHourlyCalls.length === 0 &&
|
|
(chartSource.match(/applySuccessfulHourlyDetail\(data/g) || []).length >= 5,
|
|
"all successful city detail fetch branches should use the shared success handler",
|
|
);
|
|
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 fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
|
|
const cachedDetail = await fetchHourlyForecastForCity("fallback-revalidate", { resolution: "10m" });
|
|
const revalidatedDetail = await fetchHourlyForecastForCity("fallback-revalidate", {
|
|
bypassLocalCache: true,
|
|
resolution: "10m",
|
|
});
|
|
assert(
|
|
firstDetail?.temps[0] === 1 &&
|
|
cachedDetail?.temps[0] === 1 &&
|
|
revalidatedDetail?.temps[0] === 2 &&
|
|
revalidationFetches === 2,
|
|
"bypassLocalCache should revalidate cached chart detail through the network",
|
|
);
|
|
assert(
|
|
revalidationUrls.every((url) => url.includes("force_refresh=false")),
|
|
"bypassLocalCache must not force-refresh backend observation sources",
|
|
);
|
|
|
|
clearCityDetailCache();
|
|
const cacheKey = "paris:10m";
|
|
store.set(
|
|
`polyweather_city_detail_v1:${cacheKey}`,
|
|
JSON.stringify({
|
|
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
|
|
data: {
|
|
forecastDaily: [],
|
|
localDate: "2026-05-31",
|
|
multiModelDaily: {},
|
|
probabilities: null,
|
|
temps: [21],
|
|
times: ["00:00"],
|
|
},
|
|
}),
|
|
);
|
|
assert(
|
|
__readHourlyCacheEntryForTest(cacheKey) === null,
|
|
"stale city detail cache should not suppress a fresh revalidation",
|
|
);
|
|
assert(
|
|
__readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21,
|
|
"stale city detail cache should still be available for immediate chart rendering",
|
|
);
|
|
} finally {
|
|
clearCityDetailCache();
|
|
(globalThis as any).window = originalWindow;
|
|
(globalThis as any).sessionStorage = originalSessionStorage;
|
|
(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);
|
|
}
|