feat: implement ScanTerminalDashboard UI and state management with supporting tests
This commit is contained in:
@@ -46,13 +46,6 @@ import {
|
|||||||
useUserLocalClock,
|
useUserLocalClock,
|
||||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
||||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||||
const MarketOverviewBanner = dynamic(
|
|
||||||
() =>
|
|
||||||
import(
|
|
||||||
"@/components/dashboard/scan-terminal/MarketOverviewBanner"
|
|
||||||
).then((m) => m.MarketOverviewBanner),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const MonitorPanel = dynamic(
|
const MonitorPanel = dynamic(
|
||||||
() => import("@/components/dashboard/monitoring/MonitorPanel"),
|
() => import("@/components/dashboard/monitoring/MonitorPanel"),
|
||||||
@@ -453,12 +446,6 @@ function ScanTerminalScreen() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MarketOverviewBanner
|
|
||||||
isEn={isEn}
|
|
||||||
isPro={isPro}
|
|
||||||
rows={timeSortedRows}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="scan-list-section">
|
<section className="scan-list-section">
|
||||||
<div className="scan-list-header">
|
<div className="scan-list-header">
|
||||||
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
|
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
|
||||||
|
|||||||
+20
@@ -21,4 +21,24 @@ export function runTests() {
|
|||||||
),
|
),
|
||||||
"deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data",
|
"deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("function pickRicherObservationSeries"),
|
||||||
|
"city detail merge should preserve chart observation series when refresh payload is sparse",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/metar_today_obs:\s*pickRicherObservationSeries\(\s*current\.metar_today_obs,\s*incoming\.metar_today_obs,?\s*\)/.test(
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
"deep-analysis refresh must not wipe METAR observation points used by the intraday path chart",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/settlement_today_obs:\s*pickRicherObservationSeries\(\s*current\.settlement_today_obs,\s*incoming\.settlement_today_obs,?\s*\)/.test(
|
||||||
|
source,
|
||||||
|
),
|
||||||
|
"deep-analysis refresh must not wipe settlement observation points used by the intraday path chart",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
/trend:\s*mergeTrendInfo\(\s*current\.trend,\s*incoming\.trend\s*\)/.test(source),
|
||||||
|
"deep-analysis refresh must merge trend.recent instead of replacing it with sparse trend payloads",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ function assert(condition: unknown, message: string) {
|
|||||||
|
|
||||||
export function runTests() {
|
export function runTests() {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
|
const dashboardPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"components",
|
||||||
|
"dashboard",
|
||||||
|
"ScanTerminalDashboard.tsx",
|
||||||
|
);
|
||||||
const bannerPath = path.join(
|
const bannerPath = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
"components",
|
"components",
|
||||||
@@ -14,6 +20,7 @@ export function runTests() {
|
|||||||
"scan-terminal",
|
"scan-terminal",
|
||||||
"MarketOverviewBanner.tsx",
|
"MarketOverviewBanner.tsx",
|
||||||
);
|
);
|
||||||
|
const dashboardSource = fs.readFileSync(dashboardPath, "utf8");
|
||||||
const source = fs.readFileSync(bannerPath, "utf8");
|
const source = fs.readFileSync(bannerPath, "utf8");
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
@@ -22,4 +29,8 @@ export function runTests() {
|
|||||||
!source.includes("styles.badge"),
|
!source.includes("styles.badge"),
|
||||||
"market overview banner should not render the AI overview badge",
|
"market overview banner should not render the AI overview badge",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
!dashboardSource.includes("MarketOverviewBanner"),
|
||||||
|
"scan terminal dashboard should not mount the AI market overview banner",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -509,6 +509,61 @@ function pickRicherHourly(
|
|||||||
return incomingCount >= currentCount ? incomingValue : currentValue;
|
return incomingCount >= currentCount ? incomingValue : currentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countObservationSeriesPoints<T extends { time?: string | null; temp?: number | null }>(
|
||||||
|
value: T[] | null | undefined,
|
||||||
|
) {
|
||||||
|
return (Array.isArray(value) ? value : []).filter((row) => {
|
||||||
|
const time = String(row?.time || "").trim();
|
||||||
|
const temp = Number(row?.temp);
|
||||||
|
return Boolean(time) && Number.isFinite(temp);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRicherObservationSeries<
|
||||||
|
T extends { time?: string | null; temp?: number | null },
|
||||||
|
>(
|
||||||
|
currentValue: T[] | null | undefined,
|
||||||
|
incomingValue: T[] | null | undefined,
|
||||||
|
): T[] | undefined {
|
||||||
|
const incomingCount = countObservationSeriesPoints(incomingValue);
|
||||||
|
const currentCount = countObservationSeriesPoints(currentValue);
|
||||||
|
if (incomingCount <= 0) return currentValue || undefined;
|
||||||
|
if (currentCount <= 0) return incomingValue || undefined;
|
||||||
|
return (incomingCount >= currentCount ? incomingValue : currentValue) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeTrendInfo(
|
||||||
|
currentValue: CityDetail["trend"] | undefined,
|
||||||
|
incomingValue: CityDetail["trend"] | undefined,
|
||||||
|
) {
|
||||||
|
if (!incomingValue) return currentValue;
|
||||||
|
if (!currentValue) return incomingValue;
|
||||||
|
return {
|
||||||
|
...currentValue,
|
||||||
|
...incomingValue,
|
||||||
|
recent: pickRicherObservationSeries(
|
||||||
|
currentValue.recent,
|
||||||
|
incomingValue.recent,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMgmData(
|
||||||
|
currentValue: CityDetail["mgm"] | undefined,
|
||||||
|
incomingValue: CityDetail["mgm"] | undefined,
|
||||||
|
) {
|
||||||
|
if (!incomingValue) return currentValue;
|
||||||
|
if (!currentValue) return incomingValue;
|
||||||
|
return {
|
||||||
|
...currentValue,
|
||||||
|
...incomingValue,
|
||||||
|
hourly: pickRicherObservationSeries(
|
||||||
|
currentValue.hourly,
|
||||||
|
incomingValue.hourly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pickPreferredNearbyStations(
|
function pickPreferredNearbyStations(
|
||||||
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||||
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
|
||||||
@@ -546,7 +601,16 @@ function mergeCityDetail(
|
|||||||
airport_current: incoming.airport_current || current.airport_current,
|
airport_current: incoming.airport_current || current.airport_current,
|
||||||
deb: incoming.deb || current.deb,
|
deb: incoming.deb || current.deb,
|
||||||
probabilities: incoming.probabilities || current.probabilities,
|
probabilities: incoming.probabilities || current.probabilities,
|
||||||
trend: incoming.trend || current.trend,
|
trend: mergeTrendInfo(current.trend, incoming.trend),
|
||||||
|
metar_today_obs: pickRicherObservationSeries(
|
||||||
|
current.metar_today_obs,
|
||||||
|
incoming.metar_today_obs,
|
||||||
|
),
|
||||||
|
settlement_today_obs: pickRicherObservationSeries(
|
||||||
|
current.settlement_today_obs,
|
||||||
|
incoming.settlement_today_obs,
|
||||||
|
),
|
||||||
|
mgm: mergeMgmData(current.mgm, incoming.mgm),
|
||||||
multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model),
|
multi_model: pickRicherModelMap(current.multi_model, incoming.multi_model),
|
||||||
multi_model_daily: mergeDailyModelMap(
|
multi_model_daily: mergeDailyModelMap(
|
||||||
current.multi_model_daily,
|
current.multi_model_daily,
|
||||||
|
|||||||
Reference in New Issue
Block a user