Lock decision edge cases with business state tests
The dashboard risk is now mostly contradictory state combinations rather than styling. This extracts calendar action grouping into a pure utility and adds a lightweight TypeScript business-state runner so key decision states can be asserted without adding a test dependency. Constraint: No new dependencies; use the existing TypeScript package for a local runner. Rejected: Only rely on Next build/typecheck | it cannot catch product-language regressions such as fallback AI being labeled complete. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run test:business Tested: npm run build Not-tested: Browser-rendered mobile fold interaction snapshots.
This commit is contained in:
@@ -4,298 +4,14 @@ import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
|||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||||
import {
|
import {
|
||||||
formatShortDate,
|
buildCalendarCoreReason,
|
||||||
getPeakCountdownMeta,
|
buildCalendarMeta,
|
||||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
dedupeCalendarRows,
|
||||||
|
formatCalendarCardShortDate,
|
||||||
const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60;
|
getCalendarActionGroup,
|
||||||
const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60;
|
isCalendarActionable,
|
||||||
const MINUTE_MS = 60_000;
|
type CalendarActionItem,
|
||||||
|
} from "@/components/dashboard/scan-terminal/calendar-action-utils";
|
||||||
type CalendarMeta = ReturnType<typeof getPeakCountdownMeta> & {
|
|
||||||
localWindowLabel?: string | null;
|
|
||||||
cityWindowLabel?: string | null;
|
|
||||||
startAtMs?: number | null;
|
|
||||||
endAtMs?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarActionGroup = {
|
|
||||||
key: "now" | "next" | "later" | "past";
|
|
||||||
label: string;
|
|
||||||
subtitle: string;
|
|
||||||
sort: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CalendarActionItem = {
|
|
||||||
row: ScanOpportunityRow;
|
|
||||||
meta: CalendarMeta;
|
|
||||||
reason: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeCalendarCityKey(value?: string | null) {
|
|
||||||
return String(value || "")
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\s_-]+/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarCardKey(row: ScanOpportunityRow) {
|
|
||||||
const city =
|
|
||||||
normalizeCalendarCityKey(row.city) ||
|
|
||||||
normalizeCalendarCityKey(row.city_display_name) ||
|
|
||||||
normalizeCalendarCityKey(row.display_name);
|
|
||||||
const date = String(row.selected_date || row.local_date || "").trim();
|
|
||||||
return `${city || row.id}:${date || "date-unknown"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarRowScore(row: ScanOpportunityRow) {
|
|
||||||
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
|
|
||||||
const bestByCard = new Map<string, ScanOpportunityRow>();
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const key = getCalendarCardKey(row);
|
|
||||||
const current = bestByCard.get(key);
|
|
||||||
if (!current || getCalendarRowScore(row) > getCalendarRowScore(current)) {
|
|
||||||
bestByCard.set(key, row);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [...bestByCard.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteCalendarNumber(value?: number | null) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
return Number.isFinite(numeric) ? numeric : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUserLocalDate(value: Date, locale: string) {
|
|
||||||
return value.toLocaleDateString(locale === "en-US" ? "en-US" : "zh-CN", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUserLocalTime(value: Date, locale: string) {
|
|
||||||
return value.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUserLocalWindow(
|
|
||||||
startAtMs: number,
|
|
||||||
endAtMs: number,
|
|
||||||
locale: string,
|
|
||||||
) {
|
|
||||||
const start = new Date(startAtMs);
|
|
||||||
const end = new Date(endAtMs);
|
|
||||||
const startDate = formatUserLocalDate(start, locale);
|
|
||||||
const endDate = formatUserLocalDate(end, locale);
|
|
||||||
const startTime = formatUserLocalTime(start, locale);
|
|
||||||
const endTime = formatUserLocalTime(end, locale);
|
|
||||||
if (start.toDateString() === end.toDateString()) {
|
|
||||||
return `${startDate} ${startTime}-${endTime}`;
|
|
||||||
}
|
|
||||||
return `${startDate} ${startTime} → ${endDate} ${endTime}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCalendarMeta(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
locale: string,
|
|
||||||
snapshotMs: number,
|
|
||||||
nowMs: number,
|
|
||||||
): CalendarMeta {
|
|
||||||
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
|
|
||||||
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
|
|
||||||
if (startDelta === null || endDelta === null) {
|
|
||||||
const fallback = getPeakCountdownMeta(row, locale);
|
|
||||||
return {
|
|
||||||
...fallback,
|
|
||||||
cityWindowLabel: fallback.detail,
|
|
||||||
localWindowLabel: null,
|
|
||||||
startAtMs: null,
|
|
||||||
endAtMs: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAtMs = snapshotMs + startDelta * MINUTE_MS;
|
|
||||||
const endAtMs = snapshotMs + endDelta * MINUTE_MS;
|
|
||||||
const liveStartDelta = (startAtMs - nowMs) / MINUTE_MS;
|
|
||||||
const liveEndDelta = (endAtMs - nowMs) / MINUTE_MS;
|
|
||||||
const meta = getPeakCountdownMeta(
|
|
||||||
{
|
|
||||||
...row,
|
|
||||||
window_phase: null,
|
|
||||||
minutes_until_peak_start: liveStartDelta,
|
|
||||||
minutes_until_peak_end: liveEndDelta,
|
|
||||||
},
|
|
||||||
locale,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...meta,
|
|
||||||
cityWindowLabel: meta.detail,
|
|
||||||
localWindowLabel: formatUserLocalWindow(startAtMs, endAtMs, locale),
|
|
||||||
startAtMs,
|
|
||||||
endAtMs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs: number) {
|
|
||||||
if (meta.startAtMs !== null && meta.startAtMs !== undefined) {
|
|
||||||
const endAtMs = meta.endAtMs ?? meta.startAtMs;
|
|
||||||
return (
|
|
||||||
meta.startAtMs <= nowMs + CALENDAR_UPCOMING_HORIZON_MINUTES * MINUTE_MS &&
|
|
||||||
endAtMs >= nowMs - CALENDAR_POST_PEAK_GRACE_MINUTES * MINUTE_MS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const phase = String(row.window_phase || "").toLowerCase();
|
|
||||||
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
|
|
||||||
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
|
|
||||||
|
|
||||||
if (phase === "active_peak" || (startDelta !== null && startDelta <= 0 && endDelta !== null && endDelta >= 0)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === "post_peak") {
|
|
||||||
return endDelta === null || endDelta >= -CALENDAR_POST_PEAK_GRACE_MINUTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDelta === null) {
|
|
||||||
return phase === "setup_today";
|
|
||||||
}
|
|
||||||
|
|
||||||
return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarActionGroup(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
meta: CalendarMeta,
|
|
||||||
nowMs: number,
|
|
||||||
locale: string,
|
|
||||||
): CalendarActionGroup {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const phase = String(row.window_phase || "").toLowerCase();
|
|
||||||
const startDelta =
|
|
||||||
meta.startAtMs != null ? (meta.startAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_start);
|
|
||||||
const endDelta =
|
|
||||||
meta.endAtMs != null ? (meta.endAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_end);
|
|
||||||
const isPast =
|
|
||||||
phase === "post_peak" ||
|
|
||||||
(endDelta != null && endDelta < 0) ||
|
|
||||||
meta.key === "past";
|
|
||||||
if (isPast) {
|
|
||||||
return {
|
|
||||||
key: "past",
|
|
||||||
label: isEn ? "Past peak · confirm" : "已过峰值,等待确认",
|
|
||||||
subtitle: isEn ? "Check whether a new high printed; avoid chasing if it did not." : "确认是否刷新高点;若无新高,避免追高。",
|
|
||||||
sort: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const isNow =
|
|
||||||
phase === "active_peak" ||
|
|
||||||
(startDelta != null && startDelta <= 60) ||
|
|
||||||
meta.key === "active";
|
|
||||||
if (isNow) {
|
|
||||||
return {
|
|
||||||
key: "now",
|
|
||||||
label: isEn ? "Watch now" : "现在可看",
|
|
||||||
subtitle: isEn ? "Peak window is live or close enough to require immediate checks." : "峰值窗口正在进行或即将开始,需要马上核对。",
|
|
||||||
sort: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (startDelta != null && startDelta <= 180) {
|
|
||||||
return {
|
|
||||||
key: "next",
|
|
||||||
label: isEn ? "In 1-3 hours" : "1-3 小时内",
|
|
||||||
subtitle: isEn ? "Prepare the setup and wait for the next observation." : "提前准备,只等下一轮观测确认。",
|
|
||||||
sort: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
key: "later",
|
|
||||||
label: isEn ? "Later today" : "今天稍后",
|
|
||||||
subtitle: isEn ? "Keep on the board, but do not spend attention yet." : "先放在行动面板,不需要立刻盯盘。",
|
|
||||||
sort: 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarModelUpper(row: ScanOpportunityRow) {
|
|
||||||
const values = [
|
|
||||||
finiteCalendarNumber(row.cluster_core_high),
|
|
||||||
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
|
||||||
].filter((value): value is number => value != null);
|
|
||||||
return values.length ? Math.max(...values) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarModelLower(row: ScanOpportunityRow) {
|
|
||||||
const values = [
|
|
||||||
finiteCalendarNumber(row.cluster_core_low),
|
|
||||||
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
|
||||||
].filter((value): value is number => value != null);
|
|
||||||
return values.length ? Math.min(...values) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstSentence(value?: string | null) {
|
|
||||||
const text = String(value || "").trim();
|
|
||||||
if (!text) return "";
|
|
||||||
const match = text.match(/^.*?[。.!??](?:\s|$)/);
|
|
||||||
return (match?.[0] || text).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCalendarCoreReason(
|
|
||||||
row: ScanOpportunityRow,
|
|
||||||
group: CalendarActionGroup,
|
|
||||||
locale: string,
|
|
||||||
) {
|
|
||||||
const isEn = locale === "en-US";
|
|
||||||
const tempSymbol = row.temp_symbol || "°C";
|
|
||||||
const currentTemp = finiteCalendarNumber(row.current_temp ?? row.metar_context?.last_temp);
|
|
||||||
const modelUpper = getCalendarModelUpper(row);
|
|
||||||
const modelLower = getCalendarModelLower(row);
|
|
||||||
const modelSpread =
|
|
||||||
modelUpper != null && modelLower != null ? modelUpper - modelLower : null;
|
|
||||||
if (currentTemp != null && modelUpper != null && currentTemp > modelUpper + 0.2) {
|
|
||||||
return isEn
|
|
||||||
? `Observed ${formatTemperatureValue(currentTemp, tempSymbol)} is above the model upper bound; watch whether the high keeps revising up.`
|
|
||||||
: `实测已高于模型上沿,需关注是否继续上修`;
|
|
||||||
}
|
|
||||||
if (group.key === "past") {
|
|
||||||
return isEn
|
|
||||||
? "Peak window has passed; avoid chasing if no new high prints."
|
|
||||||
: "峰值窗口已过,若无新高应避免追高";
|
|
||||||
}
|
|
||||||
if (row.metar_status?.stale_for_today || row.metar_context?.stale_for_today) {
|
|
||||||
return isEn
|
|
||||||
? "METAR is stale, so use it as background only."
|
|
||||||
: "METAR 已过旧,仅作背景参考";
|
|
||||||
}
|
|
||||||
if (currentTemp != null && modelLower != null && currentTemp < modelLower - 0.5) {
|
|
||||||
return isEn
|
|
||||||
? "Observed temperature is still below the model core; wait for the next report."
|
|
||||||
: "实测仍低于模型核心区,等待下一报文确认";
|
|
||||||
}
|
|
||||||
if (Number(row.cluster_model_count || 0) >= 4 && modelSpread != null && modelSpread <= 2) {
|
|
||||||
return isEn
|
|
||||||
? "Models are tightly aligned; the next observation should decide direction."
|
|
||||||
: "模型高度一致,等待下一报文确认方向";
|
|
||||||
}
|
|
||||||
const aiReason = firstSentence(
|
|
||||||
isEn
|
|
||||||
? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en || row.ai_reason_en || row.ai_city_thesis_en
|
|
||||||
: row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh || row.ai_reason_zh || row.ai_city_thesis_zh,
|
|
||||||
);
|
|
||||||
if (aiReason) return aiReason;
|
|
||||||
return group.key === "now"
|
|
||||||
? isEn
|
|
||||||
? "Peak timing is close; open the card and verify live evidence."
|
|
||||||
: "峰值时间接近,打开卡片核对实况证据"
|
|
||||||
: isEn
|
|
||||||
? "Keep it on the action board until the next observation."
|
|
||||||
: "先放入行动面板,等待下一轮观测";
|
|
||||||
}
|
|
||||||
|
|
||||||
const CalendarActionCard = memo(function CalendarActionCard({
|
const CalendarActionCard = memo(function CalendarActionCard({
|
||||||
item,
|
item,
|
||||||
@@ -349,7 +65,7 @@ const CalendarActionCard = memo(function CalendarActionCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="scan-calendar-meta">
|
<div className="scan-calendar-meta">
|
||||||
<span>
|
<span>
|
||||||
{formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"}
|
{formatCalendarCardShortDate(row, locale)} · {row.local_time || "--"}
|
||||||
</span>
|
</span>
|
||||||
<span>{phaseMeta.label}</span>
|
<span>{phaseMeta.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
||||||
|
import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||||
|
import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
|
||||||
|
|
||||||
|
function market(status: MarketDecisionView["status"]): MarketDecisionView {
|
||||||
|
return {
|
||||||
|
bucketLabel: "--",
|
||||||
|
confidence: "--",
|
||||||
|
edgeText: "--",
|
||||||
|
impliedText: "--",
|
||||||
|
modelText: "--",
|
||||||
|
priceText: "--",
|
||||||
|
reason: "",
|
||||||
|
status,
|
||||||
|
title: "",
|
||||||
|
tone: "watch",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ai(status: AiCityForecastState["status"], extra: Partial<AiCityForecastState> = {}): AiCityForecastState {
|
||||||
|
return { status, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const breakout = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: ai("ready"),
|
||||||
|
aiRuleEvidenceMode: false,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: market("ready"),
|
||||||
|
modelHighlyConsistent: true,
|
||||||
|
needsNextBulletin: false,
|
||||||
|
observationStale: false,
|
||||||
|
observedHighBreak: true,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: false,
|
||||||
|
peakHasPassed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(breakout.urgency, "now");
|
||||||
|
assert.equal(breakout.recommendation, "watch");
|
||||||
|
assert.match(breakout.primaryReason, /实测已突破模型上沿/);
|
||||||
|
assert.doesNotMatch(breakout.primaryReason, /等待确认|等待下一报文/);
|
||||||
|
assert.ok(breakout.badges.some((badge) => badge.label === "实测突破"));
|
||||||
|
|
||||||
|
const marketUnavailable = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: ai("ready"),
|
||||||
|
aiRuleEvidenceMode: false,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: market("unavailable"),
|
||||||
|
modelHighlyConsistent: false,
|
||||||
|
needsNextBulletin: false,
|
||||||
|
observationStale: false,
|
||||||
|
observedHighBreak: false,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: false,
|
||||||
|
peakHasPassed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(marketUnavailable.marketStatus, "unavailable");
|
||||||
|
assert.match(marketUnavailable.primaryReason, /暂无可交易价格/);
|
||||||
|
assert.doesNotMatch(marketUnavailable.primaryReason, /未接入|系统缺失|系统坏/);
|
||||||
|
|
||||||
|
const fallback = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: ai("ready", { payload: { degraded: true } }),
|
||||||
|
aiRuleEvidenceMode: true,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: market("ready"),
|
||||||
|
modelHighlyConsistent: false,
|
||||||
|
needsNextBulletin: false,
|
||||||
|
observationStale: false,
|
||||||
|
observedHighBreak: false,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: false,
|
||||||
|
peakHasPassed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(fallback.aiStatus, "fallback");
|
||||||
|
assert.equal(fallback.aiStatusLabel, "规则证据模式");
|
||||||
|
assert.notEqual(fallback.aiStatusLabel, "AI 解读已完成");
|
||||||
|
|
||||||
|
const partialStream = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: ai("loading", { streamText: "partial" }),
|
||||||
|
aiRuleEvidenceMode: false,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: market("ready"),
|
||||||
|
modelHighlyConsistent: false,
|
||||||
|
needsNextBulletin: true,
|
||||||
|
observationStale: false,
|
||||||
|
observedHighBreak: false,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: true,
|
||||||
|
peakHasPassed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(partialStream.aiStatus, "deepseek-loading");
|
||||||
|
assert.equal(partialStream.aiStatusLabel, "快速判断已完成");
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state";
|
||||||
|
import type { MarketDecisionView } from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||||
|
|
||||||
|
const readyMarket: MarketDecisionView = {
|
||||||
|
bucketLabel: "--",
|
||||||
|
confidence: "--",
|
||||||
|
edgeText: "--",
|
||||||
|
impliedText: "--",
|
||||||
|
modelText: "--",
|
||||||
|
priceText: "--",
|
||||||
|
reason: "",
|
||||||
|
status: "ready",
|
||||||
|
title: "",
|
||||||
|
tone: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const peakPassed = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: { status: "ready" },
|
||||||
|
aiRuleEvidenceMode: false,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: readyMarket,
|
||||||
|
modelHighlyConsistent: true,
|
||||||
|
needsNextBulletin: false,
|
||||||
|
observationStale: false,
|
||||||
|
observedHighBreak: false,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: false,
|
||||||
|
peakHasPassed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(peakPassed.urgency, "past");
|
||||||
|
assert.equal(peakPassed.recommendation, "avoid");
|
||||||
|
assert.match(peakPassed.primaryReason, /峰值窗口已过/);
|
||||||
|
assert.doesNotMatch(peakPassed.primaryReason, /值得关注/);
|
||||||
|
assert.deepEqual(
|
||||||
|
peakPassed.badges.map((badge) => badge.label),
|
||||||
|
["峰值窗口已过", "模型高度一致"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const staleMetar = buildCityDecisionState({
|
||||||
|
aiCityForecast: null,
|
||||||
|
aiForecast: { status: "ready" },
|
||||||
|
aiRuleEvidenceMode: false,
|
||||||
|
isEn: false,
|
||||||
|
isHkoObservation: false,
|
||||||
|
marketDecisionView: readyMarket,
|
||||||
|
modelHighlyConsistent: false,
|
||||||
|
needsNextBulletin: false,
|
||||||
|
observationStale: true,
|
||||||
|
observedHighBreak: false,
|
||||||
|
observedLowBreak: false,
|
||||||
|
observedLowLag: false,
|
||||||
|
peakHasPassed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(staleMetar.evidenceQuality, "stale");
|
||||||
|
assert.equal(staleMetar.recommendation, "background");
|
||||||
|
assert.ok(staleMetar.badges.some((badge) => badge.label === "METAR 过旧"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildCalendarCoreReason,
|
||||||
|
buildCalendarMeta,
|
||||||
|
getCalendarActionGroup,
|
||||||
|
} from "@/components/dashboard/scan-terminal/calendar-action-utils";
|
||||||
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
function row(overrides: Partial<ScanOpportunityRow>): ScanOpportunityRow {
|
||||||
|
return {
|
||||||
|
city: "London",
|
||||||
|
city_display_name: "London",
|
||||||
|
current_temp: 21,
|
||||||
|
deb_prediction: 24,
|
||||||
|
id: "london-2026-04-27",
|
||||||
|
local_date: "2026-04-27",
|
||||||
|
local_time: "4/27·00:01",
|
||||||
|
selected_date: "2026-04-27",
|
||||||
|
temp_symbol: "°C",
|
||||||
|
...overrides,
|
||||||
|
} as ScanOpportunityRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const snapshotMs = Date.UTC(2026, 3, 27, 16, 1);
|
||||||
|
const sameNow = snapshotMs;
|
||||||
|
const localTimeCase = row({
|
||||||
|
minutes_until_peak_end: 119,
|
||||||
|
minutes_until_peak_start: 0,
|
||||||
|
window_phase: "active_peak",
|
||||||
|
});
|
||||||
|
const meta = buildCalendarMeta(localTimeCase, "zh-CN", snapshotMs, sameNow);
|
||||||
|
|
||||||
|
assert.equal(meta.startAtMs, snapshotMs);
|
||||||
|
assert.ok(meta.localWindowLabel, "should expose the user's local peak window");
|
||||||
|
assert.notEqual(meta.localWindowLabel, localTimeCase.local_time);
|
||||||
|
assert.doesNotMatch(meta.localWindowLabel || "", /4\/27·00:01/);
|
||||||
|
|
||||||
|
const activeGroup = getCalendarActionGroup(localTimeCase, meta, sameNow, "zh-CN");
|
||||||
|
assert.equal(activeGroup.key, "now");
|
||||||
|
|
||||||
|
const breakoutReason = buildCalendarCoreReason(
|
||||||
|
row({
|
||||||
|
cluster_core_high: 24.7,
|
||||||
|
cluster_core_low: 23.1,
|
||||||
|
cluster_model_count: 6,
|
||||||
|
current_temp: 25.2,
|
||||||
|
model_cluster_sources: {
|
||||||
|
ecmwf: 24.4,
|
||||||
|
gfs: 24.7,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
activeGroup,
|
||||||
|
"zh-CN",
|
||||||
|
);
|
||||||
|
assert.match(breakoutReason, /实测已高于模型上沿/);
|
||||||
|
assert.doesNotMatch(breakoutReason, /等待下一报文确认方向/);
|
||||||
|
|
||||||
|
const pastMeta = buildCalendarMeta(
|
||||||
|
row({
|
||||||
|
minutes_until_peak_end: -20,
|
||||||
|
minutes_until_peak_start: -120,
|
||||||
|
window_phase: "post_peak",
|
||||||
|
}),
|
||||||
|
"zh-CN",
|
||||||
|
snapshotMs,
|
||||||
|
sameNow,
|
||||||
|
);
|
||||||
|
const pastGroup = getCalendarActionGroup(
|
||||||
|
row({ minutes_until_peak_end: -20, minutes_until_peak_start: -120, window_phase: "post_peak" }),
|
||||||
|
pastMeta,
|
||||||
|
sameNow,
|
||||||
|
"zh-CN",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(pastGroup.key, "past");
|
||||||
|
assert.match(
|
||||||
|
buildCalendarCoreReason(row({}), pastGroup, "zh-CN"),
|
||||||
|
/峰值窗口已过,若无新高应避免追高/,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
buildMarketDecisionView,
|
||||||
|
pickMarketBucketForWeatherCenter,
|
||||||
|
} from "@/components/dashboard/scan-terminal/city-card-decision-utils";
|
||||||
|
import type { MarketScan } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const unavailable = buildMarketDecisionView({
|
||||||
|
expectedHigh: 24,
|
||||||
|
isEn: false,
|
||||||
|
marketScan: { available: false },
|
||||||
|
marketStatus: "ready",
|
||||||
|
tempSymbol: "°C",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(unavailable.status, "unavailable");
|
||||||
|
assert.equal(unavailable.title, "市场价格暂不可用");
|
||||||
|
assert.match(unavailable.reason, /暂无可交易价格/);
|
||||||
|
assert.match(unavailable.reason, /天气判断仍可参考/);
|
||||||
|
assert.doesNotMatch(unavailable.reason, /未接入|系统缺失|系统坏/);
|
||||||
|
|
||||||
|
const mismatchedScan: MarketScan = {
|
||||||
|
available: true,
|
||||||
|
all_buckets: [
|
||||||
|
{
|
||||||
|
label: "40°C",
|
||||||
|
temp: 40,
|
||||||
|
unit: "C",
|
||||||
|
model_probability: 0.2,
|
||||||
|
market_price: 0.3,
|
||||||
|
yes_buy: 0.31,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
market_price: 0.3,
|
||||||
|
model_probability: 0.55,
|
||||||
|
yes_buy: 0.31,
|
||||||
|
};
|
||||||
|
const mismatched = buildMarketDecisionView({
|
||||||
|
expectedHigh: 24,
|
||||||
|
isEn: false,
|
||||||
|
marketScan: mismatchedScan,
|
||||||
|
marketStatus: "ready",
|
||||||
|
tempSymbol: "°C",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(pickMarketBucketForWeatherCenter(mismatchedScan, 24, "°C"), null);
|
||||||
|
assert.equal(mismatched.status, "ready");
|
||||||
|
assert.equal(mismatched.title, "市场温度桶需重新匹配");
|
||||||
|
assert.equal(mismatched.edgeText, "--");
|
||||||
|
assert.match(mismatched.reason, /温度桶与今日预计高点不够匹配/);
|
||||||
|
|
||||||
|
const matched = buildMarketDecisionView({
|
||||||
|
expectedHigh: 24.3,
|
||||||
|
isEn: false,
|
||||||
|
marketScan: {
|
||||||
|
available: true,
|
||||||
|
all_buckets: [
|
||||||
|
{
|
||||||
|
label: "24°C",
|
||||||
|
temp: 24,
|
||||||
|
unit: "C",
|
||||||
|
model_probability: 0.64,
|
||||||
|
market_price: 0.41,
|
||||||
|
yes_buy: 0.42,
|
||||||
|
slug: "tokyo-high-24c",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
market_price: 0.41,
|
||||||
|
model_probability: 0.64,
|
||||||
|
yes_buy: 0.42,
|
||||||
|
},
|
||||||
|
marketStatus: "ready",
|
||||||
|
tempSymbol: "°C",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(matched.status, "ready");
|
||||||
|
assert.equal(matched.bucketLabel, "24°C");
|
||||||
|
assert.equal(matched.priceText, "42¢");
|
||||||
|
assert.match(matched.reason, /模型概率 64\.0%/);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
|
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||||
|
import {
|
||||||
|
formatShortDate,
|
||||||
|
getPeakCountdownMeta,
|
||||||
|
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||||
|
|
||||||
|
export const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60;
|
||||||
|
export const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60;
|
||||||
|
const MINUTE_MS = 60_000;
|
||||||
|
|
||||||
|
export type CalendarMeta = ReturnType<typeof getPeakCountdownMeta> & {
|
||||||
|
localWindowLabel?: string | null;
|
||||||
|
cityWindowLabel?: string | null;
|
||||||
|
startAtMs?: number | null;
|
||||||
|
endAtMs?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarActionGroup = {
|
||||||
|
key: "now" | "next" | "later" | "past";
|
||||||
|
label: string;
|
||||||
|
subtitle: string;
|
||||||
|
sort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarActionItem = {
|
||||||
|
row: ScanOpportunityRow;
|
||||||
|
meta: CalendarMeta;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCalendarCityKey(value?: string | null) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_-]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarCardKey(row: ScanOpportunityRow) {
|
||||||
|
const city =
|
||||||
|
normalizeCalendarCityKey(row.city) ||
|
||||||
|
normalizeCalendarCityKey(row.city_display_name) ||
|
||||||
|
normalizeCalendarCityKey(row.display_name);
|
||||||
|
const date = String(row.selected_date || row.local_date || "").trim();
|
||||||
|
return `${city || row.id}:${date || "date-unknown"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarRowScore(row: ScanOpportunityRow) {
|
||||||
|
return Number(row.final_score || 0) * 1000 + Number(row.edge_percent || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dedupeCalendarRows(rows: ScanOpportunityRow[]) {
|
||||||
|
const bestByCard = new Map<string, ScanOpportunityRow>();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const key = getCalendarCardKey(row);
|
||||||
|
const current = bestByCard.get(key);
|
||||||
|
if (!current || getCalendarRowScore(row) > getCalendarRowScore(current)) {
|
||||||
|
bestByCard.set(key, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...bestByCard.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finiteCalendarNumber(value?: number | null) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserLocalDate(value: Date, locale: string) {
|
||||||
|
return value.toLocaleDateString(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserLocalTime(value: Date, locale: string) {
|
||||||
|
return value.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUserLocalWindow(
|
||||||
|
startAtMs: number,
|
||||||
|
endAtMs: number,
|
||||||
|
locale: string,
|
||||||
|
) {
|
||||||
|
const start = new Date(startAtMs);
|
||||||
|
const end = new Date(endAtMs);
|
||||||
|
const startDate = formatUserLocalDate(start, locale);
|
||||||
|
const endDate = formatUserLocalDate(end, locale);
|
||||||
|
const startTime = formatUserLocalTime(start, locale);
|
||||||
|
const endTime = formatUserLocalTime(end, locale);
|
||||||
|
if (start.toDateString() === end.toDateString()) {
|
||||||
|
return `${startDate} ${startTime}-${endTime}`;
|
||||||
|
}
|
||||||
|
return `${startDate} ${startTime} → ${endDate} ${endTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarMeta(
|
||||||
|
row: ScanOpportunityRow,
|
||||||
|
locale: string,
|
||||||
|
snapshotMs: number,
|
||||||
|
nowMs: number,
|
||||||
|
): CalendarMeta {
|
||||||
|
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
|
||||||
|
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
|
||||||
|
if (startDelta === null || endDelta === null) {
|
||||||
|
const fallback = getPeakCountdownMeta(row, locale);
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
cityWindowLabel: fallback.detail,
|
||||||
|
localWindowLabel: null,
|
||||||
|
startAtMs: null,
|
||||||
|
endAtMs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAtMs = snapshotMs + startDelta * MINUTE_MS;
|
||||||
|
const endAtMs = snapshotMs + endDelta * MINUTE_MS;
|
||||||
|
const liveStartDelta = (startAtMs - nowMs) / MINUTE_MS;
|
||||||
|
const liveEndDelta = (endAtMs - nowMs) / MINUTE_MS;
|
||||||
|
const meta = getPeakCountdownMeta(
|
||||||
|
{
|
||||||
|
...row,
|
||||||
|
window_phase: null,
|
||||||
|
minutes_until_peak_start: liveStartDelta,
|
||||||
|
minutes_until_peak_end: liveEndDelta,
|
||||||
|
},
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
cityWindowLabel: meta.detail,
|
||||||
|
localWindowLabel: formatUserLocalWindow(startAtMs, endAtMs, locale),
|
||||||
|
startAtMs,
|
||||||
|
endAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs: number) {
|
||||||
|
if (meta.startAtMs !== null && meta.startAtMs !== undefined) {
|
||||||
|
const endAtMs = meta.endAtMs ?? meta.startAtMs;
|
||||||
|
return (
|
||||||
|
meta.startAtMs <= nowMs + CALENDAR_UPCOMING_HORIZON_MINUTES * MINUTE_MS &&
|
||||||
|
endAtMs >= nowMs - CALENDAR_POST_PEAK_GRACE_MINUTES * MINUTE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = String(row.window_phase || "").toLowerCase();
|
||||||
|
const startDelta = finiteCalendarNumber(row.minutes_until_peak_start);
|
||||||
|
const endDelta = finiteCalendarNumber(row.minutes_until_peak_end);
|
||||||
|
|
||||||
|
if (phase === "active_peak" || (startDelta !== null && startDelta <= 0 && endDelta !== null && endDelta >= 0)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "post_peak") {
|
||||||
|
return endDelta === null || endDelta >= -CALENDAR_POST_PEAK_GRACE_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDelta === null) {
|
||||||
|
return phase === "setup_today";
|
||||||
|
}
|
||||||
|
|
||||||
|
return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarActionGroup(
|
||||||
|
row: ScanOpportunityRow,
|
||||||
|
meta: CalendarMeta,
|
||||||
|
nowMs: number,
|
||||||
|
locale: string,
|
||||||
|
): CalendarActionGroup {
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
const phase = String(row.window_phase || "").toLowerCase();
|
||||||
|
const startDelta =
|
||||||
|
meta.startAtMs != null ? (meta.startAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_start);
|
||||||
|
const endDelta =
|
||||||
|
meta.endAtMs != null ? (meta.endAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_end);
|
||||||
|
const isPast =
|
||||||
|
phase === "post_peak" ||
|
||||||
|
(endDelta != null && endDelta < 0) ||
|
||||||
|
meta.key === "past";
|
||||||
|
if (isPast) {
|
||||||
|
return {
|
||||||
|
key: "past",
|
||||||
|
label: isEn ? "Past peak · confirm" : "已过峰值,等待确认",
|
||||||
|
subtitle: isEn ? "Check whether a new high printed; avoid chasing if it did not." : "确认是否刷新高点;若无新高,避免追高。",
|
||||||
|
sort: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const isNow =
|
||||||
|
phase === "active_peak" ||
|
||||||
|
(startDelta != null && startDelta <= 60) ||
|
||||||
|
meta.key === "active";
|
||||||
|
if (isNow) {
|
||||||
|
return {
|
||||||
|
key: "now",
|
||||||
|
label: isEn ? "Watch now" : "现在可看",
|
||||||
|
subtitle: isEn ? "Peak window is live or close enough to require immediate checks." : "峰值窗口正在进行或即将开始,需要马上核对。",
|
||||||
|
sort: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (startDelta != null && startDelta <= 180) {
|
||||||
|
return {
|
||||||
|
key: "next",
|
||||||
|
label: isEn ? "In 1-3 hours" : "1-3 小时内",
|
||||||
|
subtitle: isEn ? "Prepare the setup and wait for the next observation." : "提前准备,只等下一轮观测确认。",
|
||||||
|
sort: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: "later",
|
||||||
|
label: isEn ? "Later today" : "今天稍后",
|
||||||
|
subtitle: isEn ? "Keep on the board, but do not spend attention yet." : "先放在行动面板,不需要立刻盯盘。",
|
||||||
|
sort: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarModelUpper(row: ScanOpportunityRow) {
|
||||||
|
const values = [
|
||||||
|
finiteCalendarNumber(row.cluster_core_high),
|
||||||
|
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
||||||
|
].filter((value): value is number => value != null);
|
||||||
|
return values.length ? Math.max(...values) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCalendarModelLower(row: ScanOpportunityRow) {
|
||||||
|
const values = [
|
||||||
|
finiteCalendarNumber(row.cluster_core_low),
|
||||||
|
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
||||||
|
].filter((value): value is number => value != null);
|
||||||
|
return values.length ? Math.min(...values) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstSentence(value?: string | null) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
if (!text) return "";
|
||||||
|
const match = text.match(/^.*?[。.!??](?:\s|$)/);
|
||||||
|
return (match?.[0] || text).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarCoreReason(
|
||||||
|
row: ScanOpportunityRow,
|
||||||
|
group: CalendarActionGroup,
|
||||||
|
locale: string,
|
||||||
|
) {
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
const tempSymbol = row.temp_symbol || "°C";
|
||||||
|
const currentTemp = finiteCalendarNumber(row.current_temp ?? row.metar_context?.last_temp);
|
||||||
|
const modelUpper = getCalendarModelUpper(row);
|
||||||
|
const modelLower = getCalendarModelLower(row);
|
||||||
|
const modelSpread =
|
||||||
|
modelUpper != null && modelLower != null ? modelUpper - modelLower : null;
|
||||||
|
if (currentTemp != null && modelUpper != null && currentTemp > modelUpper + 0.2) {
|
||||||
|
return isEn
|
||||||
|
? `Observed ${formatTemperatureValue(currentTemp, tempSymbol)} is above the model upper bound; watch whether the high keeps revising up.`
|
||||||
|
: `实测已高于模型上沿,需关注是否继续上修`;
|
||||||
|
}
|
||||||
|
if (group.key === "past") {
|
||||||
|
return isEn
|
||||||
|
? "Peak window has passed; avoid chasing if no new high prints."
|
||||||
|
: "峰值窗口已过,若无新高应避免追高";
|
||||||
|
}
|
||||||
|
if (row.metar_status?.stale_for_today || row.metar_context?.stale_for_today) {
|
||||||
|
return isEn
|
||||||
|
? "METAR is stale, so use it as background only."
|
||||||
|
: "METAR 已过旧,仅作背景参考";
|
||||||
|
}
|
||||||
|
if (currentTemp != null && modelLower != null && currentTemp < modelLower - 0.5) {
|
||||||
|
return isEn
|
||||||
|
? "Observed temperature is still below the model core; wait for the next report."
|
||||||
|
: "实测仍低于模型核心区,等待下一报文确认";
|
||||||
|
}
|
||||||
|
if (Number(row.cluster_model_count || 0) >= 4 && modelSpread != null && modelSpread <= 2) {
|
||||||
|
return isEn
|
||||||
|
? "Models are tightly aligned; the next observation should decide direction."
|
||||||
|
: "模型高度一致,等待下一报文确认方向";
|
||||||
|
}
|
||||||
|
const aiReason = firstSentence(
|
||||||
|
isEn
|
||||||
|
? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en || row.ai_reason_en || row.ai_city_thesis_en
|
||||||
|
: row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh || row.ai_reason_zh || row.ai_city_thesis_zh,
|
||||||
|
);
|
||||||
|
if (aiReason) return aiReason;
|
||||||
|
return group.key === "now"
|
||||||
|
? isEn
|
||||||
|
? "Peak timing is close; open the card and verify live evidence."
|
||||||
|
: "峰值时间接近,打开卡片核对实况证据"
|
||||||
|
: isEn
|
||||||
|
? "Keep it on the action board until the next observation."
|
||||||
|
: "先放入行动面板,等待下一轮观测";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCalendarCardShortDate(row: ScanOpportunityRow, locale: string) {
|
||||||
|
return formatShortDate(row.selected_date || row.local_date, locale);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "node scripts/sync-next-server-chunks.mjs --clean-root && next dev",
|
"dev": "node scripts/sync-next-server-chunks.mjs --clean-root && next dev",
|
||||||
"build": "next build && node scripts/sync-next-server-chunks.mjs",
|
"build": "next build && node scripts/sync-next-server-chunks.mjs",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test:business": "node scripts/run-business-state-tests.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import ts from "typescript";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const nodeRequire = createRequire(import.meta.url);
|
||||||
|
const moduleCache = new Map();
|
||||||
|
|
||||||
|
function toWindowsSafePath(value) {
|
||||||
|
return process.platform === "win32" && value.startsWith("/")
|
||||||
|
? value.slice(1)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModule(specifier, fromFile) {
|
||||||
|
if (specifier.startsWith("@/")) {
|
||||||
|
return resolveFile(path.join(projectRoot, specifier.slice(2)));
|
||||||
|
}
|
||||||
|
if (specifier.startsWith(".")) {
|
||||||
|
return resolveFile(path.resolve(path.dirname(fromFile), specifier));
|
||||||
|
}
|
||||||
|
return specifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFile(basePath) {
|
||||||
|
const candidates = [
|
||||||
|
basePath,
|
||||||
|
`${basePath}.ts`,
|
||||||
|
`${basePath}.tsx`,
|
||||||
|
`${basePath}.js`,
|
||||||
|
`${basePath}.jsx`,
|
||||||
|
path.join(basePath, "index.ts"),
|
||||||
|
path.join(basePath, "index.tsx"),
|
||||||
|
path.join(basePath, "index.js"),
|
||||||
|
];
|
||||||
|
const match = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Cannot resolve module file: ${basePath}`);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalModule(filename) {
|
||||||
|
const normalized = path.normalize(filename);
|
||||||
|
if (moduleCache.has(normalized)) return moduleCache.get(normalized).exports;
|
||||||
|
|
||||||
|
const source = fs.readFileSync(normalized, "utf8");
|
||||||
|
const transpiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2022,
|
||||||
|
},
|
||||||
|
fileName: normalized,
|
||||||
|
}).outputText;
|
||||||
|
|
||||||
|
const module = { exports: {} };
|
||||||
|
moduleCache.set(normalized, module);
|
||||||
|
|
||||||
|
const localRequire = (specifier) => {
|
||||||
|
const resolved = resolveModule(specifier, normalized);
|
||||||
|
if (typeof resolved === "string" && path.isAbsolute(resolved)) {
|
||||||
|
return loadLocalModule(resolved);
|
||||||
|
}
|
||||||
|
return nodeRequire(specifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = `(function (exports, require, module, __filename, __dirname) {\n${transpiled}\n})`;
|
||||||
|
const compiled = vm.runInThisContext(wrapper, { filename: normalized });
|
||||||
|
compiled(module.exports, localRequire, module, normalized, path.dirname(normalized));
|
||||||
|
return module.exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTests(dir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return collectTests(fullPath);
|
||||||
|
return /\.test\.tsx?$/.test(entry.name) ? [fullPath] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const testsRoot = path.join(projectRoot, "components", "dashboard", "scan-terminal", "__tests__");
|
||||||
|
const testFiles = fs.existsSync(testsRoot) ? collectTests(testsRoot).sort() : [];
|
||||||
|
|
||||||
|
if (!testFiles.length) {
|
||||||
|
throw new Error(`No business state tests found under ${testsRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
for (const file of testFiles) {
|
||||||
|
const exported = loadLocalModule(toWindowsSafePath(file));
|
||||||
|
const run = exported.runTests || exported.default;
|
||||||
|
if (typeof run !== "function") {
|
||||||
|
throw new Error(`${file} must export runTests()`);
|
||||||
|
}
|
||||||
|
await run();
|
||||||
|
passed += 1;
|
||||||
|
console.log(`✓ ${path.relative(projectRoot, file)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Business state tests passed: ${passed}/${testFiles.length}`);
|
||||||
Reference in New Issue
Block a user