Files
PolyWeather/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts
T
2026-06-01 05:56:09 +08:00

355 lines
15 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 {
__getInitialDetailLoadDelayMsForTest,
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__resolveCityDetailFromBatchForTest,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
} 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(
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("2 * 60_000"),
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
);
assert(
chartSource.includes("loadTemperatureChartCanvasModule") &&
chartSource.includes("preloadTemperatureChartCanvas"),
"terminal chart canvas should expose a preload hook for the dynamic chart chunk",
);
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(
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })") &&
chartSource.includes("setHourly(data)"),
"visible chart fallback must refresh the full city detail payload at the current chart resolution when SSE patches stop",
);
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(
__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(
__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("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("resolveCityDetailBatchWithSingleFallback") &&
flushCityDetailBatchBlock.includes("resolveBatchWaiters(waiters, null)"),
"single-city detail fallback should be reserved for whole-batch failures rather than successful batch misses",
);
const fetchHourlyBlock = chartLogicSource.match(/async function fetchHourlyForecastForCity[\s\S]*?\r?\n}\r?\n\r?\nfunction fetchCityDetailWithTimeout/)?.[0] || "";
assert(
fetchHourlyBlock.includes("queueCityDetailBatch(city, resParam)") &&
!fetchHourlyBlock.includes("runQueuedHourlyDetailRequest"),
"first-paint and background city detail refreshes should both enter the batch queue before 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(
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(
__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 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 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;
}
}
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);
}