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:
@@ -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%/);
|
||||
}
|
||||
Reference in New Issue
Block a user