2026-08-29 20:14:42 +08:00
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
|
|
|
|
|
|
|
|
const requestJson = vi.hoisted(() => vi.fn())
|
|
|
|
|
|
|
|
|
|
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
|
|
|
|
|
|
|
|
|
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
|
|
|
|
|
|
|
|
|
|
const publication = {
|
|
|
|
|
publication_id: "publication-1",
|
|
|
|
|
target_trade_date: "2026-08-28",
|
|
|
|
|
status: "success",
|
|
|
|
|
source_version: "tushare-pro-v1",
|
|
|
|
|
universe_version: "eastmoney-dc-v1",
|
|
|
|
|
metric_versions: ["zhixing_amount_net_bn_v1"],
|
|
|
|
|
input_hash: "a".repeat(64),
|
|
|
|
|
coverage: "0.99",
|
|
|
|
|
started_at: "2026-08-28T08:00:00Z",
|
|
|
|
|
finished_at: "2026-08-28T08:05:00Z",
|
|
|
|
|
error_summary: null,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rankingPayload = {
|
|
|
|
|
status: "success",
|
|
|
|
|
requested_trade_date: "2026-08-28",
|
|
|
|
|
sector_type: "concept",
|
|
|
|
|
view: "rank_change",
|
|
|
|
|
rank_change_metric: "amount",
|
|
|
|
|
rank_change_days: 5,
|
|
|
|
|
side: "top",
|
|
|
|
|
search: "机器人",
|
|
|
|
|
publication,
|
|
|
|
|
definition: {
|
|
|
|
|
metric_kind: "amount",
|
|
|
|
|
metric_version: "zhixing_amount_net_bn_v1",
|
|
|
|
|
label: "主力净流入(知行独立实现)",
|
|
|
|
|
unit: "CNY_100M",
|
|
|
|
|
implementation_kind: "independent",
|
|
|
|
|
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
|
|
|
|
|
},
|
|
|
|
|
page: 2,
|
|
|
|
|
page_size: 10,
|
|
|
|
|
total: 1,
|
|
|
|
|
rows: [
|
|
|
|
|
{
|
|
|
|
|
trade_date: "2026-08-28",
|
|
|
|
|
sector_type: "concept",
|
|
|
|
|
sector_code: "BK0001.DC",
|
|
|
|
|
sector_name: "机器人",
|
|
|
|
|
metric_kind: "amount",
|
|
|
|
|
metric_version: "zhixing_amount_net_bn_v1",
|
|
|
|
|
implementation_kind: "independent",
|
|
|
|
|
unit: "CNY_100M",
|
|
|
|
|
metric_value: "12.5",
|
|
|
|
|
quality: "available",
|
|
|
|
|
member_count: 20,
|
|
|
|
|
valid_sample_count: 19,
|
|
|
|
|
membership_coverage: "1",
|
|
|
|
|
moneyflow_coverage: "0.95",
|
|
|
|
|
rank_position: 1,
|
|
|
|
|
rank_percentile: "100",
|
|
|
|
|
rank_change_days: 5,
|
|
|
|
|
rank_change: 3,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("sector radar API adapters", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
requestJson.mockReset()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
|
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
|
requestJson.mockResolvedValue({
|
|
|
|
|
status: "success",
|
|
|
|
|
available_dates: ["2026-08-28"],
|
|
|
|
|
current_attempt: publication,
|
|
|
|
|
last_good: publication,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const result = await getSectorRadarDates(signal)
|
|
|
|
|
|
|
|
|
|
expect(requestJson).toHaveBeenCalledWith("/api/v1/sector-radar/dates", {
|
|
|
|
|
signal,
|
|
|
|
|
})
|
|
|
|
|
expect(result.last_good?.coverage).toBe(0.99)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("maps URL-backed filters to the ranking HTTP contract", async () => {
|
|
|
|
|
const signal = new AbortController().signal
|
|
|
|
|
requestJson.mockResolvedValue(rankingPayload)
|
|
|
|
|
|
|
|
|
|
const result = await getSectorRadarRankings(
|
|
|
|
|
{
|
|
|
|
|
tradeDate: "2026-08-28",
|
|
|
|
|
sectorType: "concept",
|
|
|
|
|
view: "rank_change",
|
|
|
|
|
rankChangeMetric: "amount",
|
|
|
|
|
rankChangeDays: 5,
|
|
|
|
|
side: "top",
|
|
|
|
|
search: " 机器人 ",
|
|
|
|
|
page: 2,
|
|
|
|
|
pageSize: 10,
|
|
|
|
|
},
|
|
|
|
|
signal,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const [input, init] = requestJson.mock.calls[0] as [
|
|
|
|
|
string,
|
|
|
|
|
{ signal?: AbortSignal },
|
|
|
|
|
]
|
|
|
|
|
const params = new URL(input, "http://localhost").searchParams
|
|
|
|
|
expect(input).toContain("/api/v1/sector-radar/rankings?")
|
|
|
|
|
expect(params.get("trade_date")).toBe("2026-08-28")
|
|
|
|
|
expect(params.get("sector_type")).toBe("concept")
|
|
|
|
|
expect(params.get("view")).toBe("rank_change")
|
|
|
|
|
expect(params.get("rank_change_metric")).toBe("amount")
|
|
|
|
|
expect(params.get("rank_change_days")).toBe("5")
|
|
|
|
|
expect(params.get("side")).toBe("top")
|
|
|
|
|
expect(params.get("search")).toBe("机器人")
|
|
|
|
|
expect(params.get("page")).toBe("2")
|
|
|
|
|
expect(params.get("page_size")).toBe("10")
|
|
|
|
|
expect(init).toEqual({ signal })
|
|
|
|
|
expect(result.rows[0]?.metric_value).toBe(12.5)
|
|
|
|
|
expect(result.rows[0]?.rank_percentile).toBe(100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rejects unknown stable enum values at the feature boundary", async () => {
|
|
|
|
|
requestJson.mockResolvedValue({ ...rankingPayload, view: "private_score" })
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
getSectorRadarRankings({
|
|
|
|
|
sectorType: "concept",
|
|
|
|
|
view: "amount",
|
|
|
|
|
rankChangeMetric: "amount",
|
|
|
|
|
rankChangeDays: 1,
|
|
|
|
|
side: "all",
|
|
|
|
|
page: 1,
|
|
|
|
|
pageSize: 20,
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("Invalid sector radar response: rankings.view")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("rejects non-finite decimal strings instead of rendering them", async () => {
|
|
|
|
|
requestJson.mockResolvedValue({
|
|
|
|
|
...rankingPayload,
|
|
|
|
|
rows: [{ ...rankingPayload.rows[0], metric_value: "NaN" }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
getSectorRadarRankings({
|
|
|
|
|
sectorType: "concept",
|
|
|
|
|
view: "amount",
|
|
|
|
|
rankChangeMetric: "amount",
|
|
|
|
|
rankChangeDays: 1,
|
|
|
|
|
side: "all",
|
|
|
|
|
page: 1,
|
|
|
|
|
pageSize: 20,
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("rankings.rows[0].metric_value must be finite")
|
|
|
|
|
})
|
2026-08-29 20:36:44 +08:00
|
|
|
|
|
|
|
|
it("rejects a zero rank percentile that the domain cannot produce", async () => {
|
|
|
|
|
requestJson.mockResolvedValue({
|
|
|
|
|
...rankingPayload,
|
|
|
|
|
rows: [{ ...rankingPayload.rows[0], rank_percentile: "0" }],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
getSectorRadarRankings({
|
|
|
|
|
sectorType: "concept",
|
|
|
|
|
view: "amount",
|
|
|
|
|
rankChangeMetric: "amount",
|
|
|
|
|
rankChangeDays: 1,
|
|
|
|
|
side: "all",
|
|
|
|
|
page: 1,
|
|
|
|
|
pageSize: 20,
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
|
|
|
|
})
|
2026-08-29 20:14:42 +08:00
|
|
|
})
|