强类型隔离图表观测和详情数据

This commit is contained in:
2569718930@qq.com
2026-06-16 23:30:13 +08:00
parent 53cf5b556b
commit 491415427c
5 changed files with 159 additions and 84 deletions
@@ -90,7 +90,7 @@ export async function runTests() {
chartSource.includes("LIVE_OBSERVATION_FALLBACK_MS = DASHBOARD_REFRESH_POLICY_MS.liveObservationFallback") &&
chartSource.includes("refreshLiveObservation") &&
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") &&
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",
@@ -137,7 +137,7 @@ export async function runTests() {
);
assert(
chartSource.includes("fetchLiveObservationForCity") &&
chartSource.includes("mergeObservationPayloadIntoHourly") &&
chartSource.includes("mergeObservationSnapshotIntoHourly") &&
!chartSource.includes("rememberHourlyDetailSnapshot"),
"visible chart fallback must use the no-store observation endpoint without writing live overlays into full-detail cache",
);
@@ -167,9 +167,30 @@ export async function runTests() {
assert(
chartLogicSource.includes("/api/city/${encodeURIComponent(city)}/observation") &&
chartLogicSource.includes('cache: "no-store"') &&
chartLogicSource.includes("mergeObservationPayloadIntoHourly"),
chartLogicSource.includes("mergeObservationSnapshotIntoHourly"),
"live observation fetches must call the no-store per-city observation endpoint and merge without touching cached model detail",
);
assert(
/type FullChartDetail\s*=\s*NonNullable<HourlyForecast>\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 HourlyForecast",
);
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 fetchHourlyForecastForCity\([\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"'),
@@ -232,8 +232,8 @@ export function runTests() {
const fallbackRefreshBlock = chart.match(/const refreshLiveObservation = \(\) => \{[\s\S]*?\n \};/)?.[0] || "";
assert(
fallbackRefreshBlock.includes("fetchLiveObservationForCity") &&
fallbackRefreshBlock.includes("applyLiveObservationPayload") &&
chart.includes("mergeObservationPayloadIntoHourly") &&
fallbackRefreshBlock.includes("applyLiveObservationSnapshot") &&
chart.includes("mergeObservationSnapshotIntoHourly") &&
!fallbackRefreshBlock.includes("runHourlyDetailFetch") &&
!fallbackRefreshBlock.includes("ignoreCache: true") &&
!fallbackRefreshBlock.includes("setIsHourlyLoading(true)"),
@@ -243,10 +243,10 @@ export function runTests() {
!chart.includes("rememberHourlyDetailSnapshot"),
"temperature chart must not write row/SSE/observation overlays back into the full-detail cache",
);
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationPayload\]\);/)?.[0] || "";
const resyncBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!resyncVersion \|\| !city\) return;[\s\S]*?\}, \[resyncVersion, city, applyLiveObservationSnapshot\]\);/)?.[0] || "";
assert(
resyncBlock.includes("fetchLiveObservationForCity") &&
resyncBlock.includes("applyLiveObservationPayload") &&
resyncBlock.includes("applyLiveObservationSnapshot") &&
!resyncBlock.includes("runHourlyDetailFetch") &&
!resyncBlock.includes("ignoreCache: true") &&
!resyncBlock.includes("setIsHourlyLoading(true)"),
@@ -3,14 +3,16 @@ import type { CityDetail } from "@/lib/dashboard-types";
import { buildChartTimeAxis, buildDebBaselinePath } from "@/lib/temperature-chart-paths";
import {
buildFullDayChartData,
mergeObservationPayloadIntoHourly,
mergeObservationSnapshotIntoHourly,
mergeHourlyWithLiveObservations,
mergePatchIntoHourly,
mergeRowObservationIntoHourly,
observationPayloadToSnapshot,
readCachedHourlyForInitialRow,
rememberHourlyDetailSnapshot,
selectInitialHourlyForRowChange,
seedHourlyForecastFromRow,
toFullChartDetail,
_hourlyCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
@@ -954,13 +956,13 @@ export function runTests() {
tz_offset_seconds: 8 * 3600,
metar_context: { source: "amsc_awos" },
} as any;
rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow));
rememberHourlyDetailSnapshot("chengdu", "1m", seedHourlyForecastFromRow(cachedChengduRow) as any);
assert(
!_hourlyCache.has(chengduCacheKey),
"instant-restore cache must not persist a row-only seed that would block the full detail fetch",
);
const cachedChengduDetail = {
const cachedChengduDetail = toFullChartDetail({
...seedHourlyForecastFromRow(cachedChengduRow),
localDate: "2026-06-15",
times: ["08:00", "09:00"],
@@ -975,7 +977,8 @@ export function runTests() {
runwayPlateHistory: {
"02L/20R": [{ timestamp: "2026-06-15T08:55:00Z", temp_c: 26.2 }],
},
} as any;
} as any);
if (!cachedChengduDetail) throw new Error("test fixture should produce a full chart detail");
const cachedChengduLivePatch = mergePatchIntoHourly(cachedChengduDetail, {
city: "chengdu",
revision: 7,
@@ -985,7 +988,9 @@ export function runTests() {
runway_points: [{ runway: "02L/20R", temp: 26.8 }],
},
} as any);
rememberHourlyDetailSnapshot("chengdu", "1m", cachedChengduLivePatch);
const cachedChengduPatchedDetail = toFullChartDetail(cachedChengduLivePatch);
if (!cachedChengduPatchedDetail) throw new Error("live-merged detail should preserve full chart detail fields");
rememberHourlyDetailSnapshot("chengdu", "1m", cachedChengduPatchedDetail);
const restoredChengdu = _hourlyCache.get(chengduCacheKey)?.data;
assert(
restoredChengdu?.modelCurves?.ECMWF?.length === 2 &&
@@ -1025,23 +1030,25 @@ export function runTests() {
"02L/20R": [{ time: "2026-06-15T17:45:00+08:00", tdz_temp: 27.1, end_temp: 26.8 }],
},
};
const observationMergedChengdu = mergeObservationPayloadIntoHourly(
const observationSnapshot = observationPayloadToSnapshot(observationOnlyPayload as any);
if (!observationSnapshot) throw new Error("test observation payload should produce an observation snapshot");
const observationMergedChengdu = mergeObservationSnapshotIntoHourly(
cachedChengduDetail,
observationOnlyPayload as any,
observationSnapshot,
);
assert(
observationMergedChengdu?.airportCurrent?.temp === 27.1 &&
observationMergedChengdu?.airportPrimary?.source_code === "amsc_awos",
"observation endpoint payload should update the current observation block",
"observation endpoint snapshot should update the current observation block",
);
assert(
observationMergedChengdu?.modelCurves?.ECMWF?.length === 2 &&
observationMergedChengdu?.debHourlyPath?.temps?.includes(30.9),
"observation endpoint payload must not clear DEB or multi-model chart detail",
"observation endpoint snapshot must not clear DEB or multi-model chart detail",
);
assert(
(observationMergedChengdu?.runwayPlateHistory?.["02L/20R"] || []).length === 2,
"observation endpoint payload should append fresh runway history onto cached detail history",
"observation endpoint snapshot should append fresh runway history onto cached detail history",
);
_hourlyCache.clear();
@@ -1055,13 +1062,14 @@ export function runTests() {
temp_symbol: "°C",
tz_offset_seconds: 0,
} as any;
rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", {
const cacheDetail = toFullChartDetail({
...seedHourlyForecastFromRow(row),
times: ["09:00"],
temps: [20 + i / 100],
modelTimes: ["09:00"],
modelCurves: { ECMWF: [20 + i / 100] },
} as any);
if (cacheDetail) rememberHourlyDetailSnapshot(`cache-city-${i}`, "10m", cacheDetail);
}
assert(
_hourlyCache.size <= 160,