Stabilize the decision workspace data boundaries
The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests. Constraint: Preserve existing global scan-terminal class names and API responses during the refactor Constraint: No new dependencies; keep this as a file-boundary cleanup Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildAiCityErrorForecastState,
|
||||
buildAiCityForecastCacheKey,
|
||||
buildAiCityForecastKey,
|
||||
buildAiCityProgressForecastState,
|
||||
buildAiCityReadyForecastState,
|
||||
readReadyCachedAiForecastState,
|
||||
} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state";
|
||||
import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache";
|
||||
import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { localStorage },
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
|
||||
return {
|
||||
airport_current: {
|
||||
raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
temp: 21,
|
||||
},
|
||||
current: {
|
||||
temp: 21,
|
||||
},
|
||||
local_date: "2026-04-28",
|
||||
metar_status: {
|
||||
last_observation_time: "2026-04-28T00:00:00Z",
|
||||
stale_for_today: false,
|
||||
},
|
||||
name: "Test City",
|
||||
temp_symbol: "°C",
|
||||
...extra,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
function readyPayload(extra: Partial<AiCityForecastPayload> = {}): AiCityForecastPayload {
|
||||
return {
|
||||
city_forecast: {
|
||||
confidence: "medium",
|
||||
final_judgment_en: "Centered near 25°C.",
|
||||
final_judgment_zh: "预计最高温以 25°C 为中枢。",
|
||||
metar_read_en: "Latest METAR supports the path.",
|
||||
metar_read_zh: "最新 METAR 支撑当前路径。",
|
||||
model_cluster_note_en: "Models are clustered.",
|
||||
model_cluster_note_zh: "模型较集中。",
|
||||
predicted_max: 25,
|
||||
range_high: 26,
|
||||
range_low: 24,
|
||||
reasoning_en: "Evidence is aligned.",
|
||||
reasoning_zh: "证据一致。",
|
||||
risks_en: [],
|
||||
risks_zh: [],
|
||||
unit: "°C",
|
||||
},
|
||||
status: "ready",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const storage = installLocalStorageMock();
|
||||
storage.clear();
|
||||
|
||||
const forecastKey = buildAiCityForecastKey({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
locale: "zh-CN",
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
const cacheKey = buildAiCityForecastCacheKey(forecastKey);
|
||||
const payload = readyPayload();
|
||||
writeCachedPayload(cacheKey, payload);
|
||||
|
||||
const cachedReady = readReadyCachedAiForecastState(cacheKey, 0);
|
||||
assert.equal(cachedReady?.status, "ready");
|
||||
assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25);
|
||||
|
||||
const degradedCacheKey = `${cacheKey}:degraded`;
|
||||
writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true }));
|
||||
assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null);
|
||||
assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null);
|
||||
|
||||
const currentLoading: AiCityForecastState = {
|
||||
status: "loading",
|
||||
streamText: "已有快速判断",
|
||||
};
|
||||
const callingAiProgress = buildAiCityProgressForecastState({
|
||||
cacheKey: `${cacheKey}:progress`,
|
||||
current: currentLoading,
|
||||
isEn: false,
|
||||
progress: {
|
||||
message_zh: "DeepSeek 正在补充机场报文细节",
|
||||
stage: "calling_ai",
|
||||
},
|
||||
});
|
||||
assert.equal(callingAiProgress?.streamText, "已有快速判断");
|
||||
|
||||
const errorState = buildAiCityErrorForecastState({
|
||||
cacheKey: `${cacheKey}:error`,
|
||||
detail: cityDetail(),
|
||||
error: new Error("timeout"),
|
||||
isEn: false,
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
assert.equal(errorState.status, "ready");
|
||||
assert.equal(errorState.payload?.status, "timeout_fallback");
|
||||
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
|
||||
|
||||
const hkoState = buildAiCityErrorForecastState({
|
||||
cacheKey: `${cacheKey}:hko`,
|
||||
detail: cityDetail({
|
||||
airport_current: null,
|
||||
current: {
|
||||
settlement_source: "hko",
|
||||
temp: 30,
|
||||
},
|
||||
settlement_station: {
|
||||
settlement_source: "hko",
|
||||
},
|
||||
} as unknown as Partial<CityDetail>),
|
||||
error: new Error("timeout"),
|
||||
isEn: false,
|
||||
report: "",
|
||||
});
|
||||
const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || "";
|
||||
assert.doesNotMatch(hkoRead, /METAR|机场报文/);
|
||||
assert.match(hkoRead, /官方观测|30\.0°C/);
|
||||
|
||||
const degradedReadyState = buildAiCityReadyForecastState({
|
||||
cacheKey: `${cacheKey}:ready-degraded`,
|
||||
detail: cityDetail(),
|
||||
isEn: false,
|
||||
payload: readyPayload({ degraded: true }),
|
||||
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
|
||||
});
|
||||
assert.equal(degradedReadyState.status, "ready");
|
||||
assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildCityMarketScanCacheKey,
|
||||
deriveCityMarketScanView,
|
||||
resolveCityMarketScanSnapshot,
|
||||
writeCachedCityMarketScan,
|
||||
} from "@/components/dashboard/scan-terminal/market-scan-state";
|
||||
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
function installLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { localStorage },
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
function marketScan(label = "cached"): MarketScan {
|
||||
return {
|
||||
generated_at: "2026-04-28T00:00:00Z",
|
||||
label,
|
||||
} as unknown as MarketScan;
|
||||
}
|
||||
|
||||
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
|
||||
return {
|
||||
local_date: "2026-04-28",
|
||||
name: "Test City",
|
||||
...extra,
|
||||
} as CityDetail;
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const storage = installLocalStorageMock();
|
||||
storage.clear();
|
||||
|
||||
const embeddedScan = marketScan("embedded");
|
||||
const embeddedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail({ market_scan: embeddedScan }),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
});
|
||||
assert.equal(embeddedSnapshot.action, "success");
|
||||
if (embeddedSnapshot.action === "success") {
|
||||
assert.equal(embeddedSnapshot.payload, embeddedScan);
|
||||
assert.equal(embeddedSnapshot.shouldWriteCache, true);
|
||||
}
|
||||
|
||||
const cacheKey = buildCityMarketScanCacheKey({
|
||||
detailCityName: "Test City",
|
||||
localDate: "2026-04-28",
|
||||
});
|
||||
const cachedScan = marketScan("cached");
|
||||
writeCachedCityMarketScan(cacheKey, cachedScan);
|
||||
const cachedSnapshot = resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(cachedSnapshot.action, "success");
|
||||
if (cachedSnapshot.action === "success") {
|
||||
assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached");
|
||||
}
|
||||
|
||||
storage.clear();
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: false,
|
||||
}).action,
|
||||
"reset",
|
||||
);
|
||||
assert.equal(
|
||||
resolveCityMarketScanSnapshot({
|
||||
detail: cityDetail(),
|
||||
detailCityName: "Test City",
|
||||
enabled: true,
|
||||
}).action,
|
||||
"fetch",
|
||||
);
|
||||
|
||||
const previous = marketScan("previous");
|
||||
const loadingRemote: RemoteData<MarketScan> = {
|
||||
previous,
|
||||
status: "loading",
|
||||
};
|
||||
const loadingView = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: loadingRemote,
|
||||
});
|
||||
assert.equal(loadingView.marketStatus, "loading");
|
||||
assert.equal(loadingView.marketScan, previous);
|
||||
|
||||
const errorWithPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
previous,
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithPrevious.marketStatus, "ready");
|
||||
assert.equal(errorWithPrevious.marketScan, previous);
|
||||
|
||||
const errorWithoutPrevious = deriveCityMarketScanView({
|
||||
detailMarketScan: null,
|
||||
marketRemote: {
|
||||
error: "network",
|
||||
status: "error",
|
||||
},
|
||||
});
|
||||
assert.equal(errorWithoutPrevious.marketStatus, "failed");
|
||||
assert.equal(errorWithoutPrevious.marketScan, null);
|
||||
}
|
||||
Reference in New Issue
Block a user