07c5b25043
- remove the sector_type parameter from the selection port, adapter, and use case; the adapter now passes SectorType.INDUSTRY directly, deleting the dead concept vocabulary mapping - stop parsing concepts/concept_total/concept_limit from the sector-radar membership response in the web API layer and narrow the response type to industries only - update tests and the task PRD accordingly; backend pyright errors drop from the 16 baseline to 14
335 lines
9.3 KiB
TypeScript
335 lines
9.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
|
|
const requestJson = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
|
|
|
import {
|
|
getSectorRadarHistory,
|
|
getSectorRadarDetail,
|
|
parseRadarDetailResponse,
|
|
getSectorRadarDates,
|
|
getSectorRadarRankings,
|
|
getStockSectorMembership,
|
|
} 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,
|
|
},
|
|
],
|
|
}
|
|
|
|
const historyMetric = {
|
|
rank_position: 1,
|
|
rank_percentile: "100",
|
|
pool_size: 10,
|
|
metric_value: "0.125",
|
|
missing: false,
|
|
in_top: true,
|
|
in_bottom: false,
|
|
}
|
|
const historyPayload = {
|
|
status: "success",
|
|
requested_trade_date: "2026-08-28",
|
|
trade_date: "2026-08-28",
|
|
sector_type: "concept",
|
|
sector_code: "BK01",
|
|
sector_name: "机器人",
|
|
publication,
|
|
window_size: 30,
|
|
available_days: 1,
|
|
points: [
|
|
{
|
|
trade_date: "2026-08-28",
|
|
publication_id: "publication-1",
|
|
swing: historyMetric,
|
|
ratio: historyMetric,
|
|
amount: historyMetric,
|
|
},
|
|
],
|
|
}
|
|
const detailPayload = {
|
|
...historyPayload,
|
|
pct_change: "-1.25",
|
|
leading_stock: { ts_code: "000001.SZ", name: null },
|
|
summary: {
|
|
swing: historyMetric,
|
|
ratio: historyMetric,
|
|
amount: historyMetric,
|
|
},
|
|
history: historyPayload,
|
|
members: [
|
|
{
|
|
ts_code: "000001.SZ",
|
|
name: "测试成员",
|
|
pct_change: "-1.5",
|
|
net_amount_yuan: "120000000",
|
|
active_buy_net_amount_yuan: null,
|
|
},
|
|
],
|
|
leaders: {
|
|
pct_change: { top: [], bottom: [] },
|
|
net_amount_yuan: { top: [], bottom: [] },
|
|
active_buy_net_amount_yuan: { top: [], bottom: [] },
|
|
},
|
|
similar_sectors: [],
|
|
}
|
|
|
|
describe("sector radar API adapters", () => {
|
|
beforeEach(() => {
|
|
requestJson.mockReset()
|
|
})
|
|
|
|
it("reads persisted sector resources and preserves nullable independent metrics", async () => {
|
|
const signal = new AbortController().signal
|
|
const query = {
|
|
sectorType: "concept" as const,
|
|
sectorCode: "BK01",
|
|
tradeDate: "2026-08-28",
|
|
}
|
|
requestJson.mockResolvedValue(historyPayload)
|
|
expect(
|
|
(await getSectorRadarHistory(query, signal)).points[0]?.ratio
|
|
.metric_value,
|
|
).toBe(0.125)
|
|
expect(requestJson).toHaveBeenLastCalledWith(
|
|
"/api/v1/sector-radar/sectors/concept/BK01/history?trade_date=2026-08-28",
|
|
{ signal },
|
|
)
|
|
requestJson.mockResolvedValue(detailPayload)
|
|
const detail = await getSectorRadarDetail(query, signal)
|
|
expect(requestJson).toHaveBeenLastCalledWith(
|
|
"/api/v1/sector-radar/sectors/concept/BK01/detail?trade_date=2026-08-28",
|
|
{ signal },
|
|
)
|
|
expect(detail.pct_change).toBe(-1.25)
|
|
expect(detail.leading_stock?.name).toBeNull()
|
|
expect(detail.members[0]).toMatchObject({
|
|
net_amount_yuan: 120000000,
|
|
active_buy_net_amount_yuan: null,
|
|
})
|
|
})
|
|
|
|
it.each(["NaN", "Infinity", "0x10", "", true])(
|
|
"rejects malformed detail Decimal %s",
|
|
(value) => {
|
|
expect(() =>
|
|
parseRadarDetailResponse({ ...detailPayload, pct_change: value }),
|
|
).toThrow("detail.pct_change")
|
|
},
|
|
)
|
|
|
|
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")
|
|
})
|
|
|
|
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")
|
|
})
|
|
})
|
|
|
|
describe("stock sector membership adapter", () => {
|
|
const membershipPayload = {
|
|
status: "success",
|
|
ts_code: "000001.SZ",
|
|
requested_trade_date: "2026-08-28",
|
|
trade_date: "2026-08-28",
|
|
industries: [{ code: "BK0901.DC", name: "银行" }],
|
|
}
|
|
|
|
it("requests the snake-case endpoint and validates the payload", async () => {
|
|
requestJson.mockResolvedValue(membershipPayload)
|
|
|
|
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
|
|
|
expect(requestJson).toHaveBeenCalledWith(
|
|
"/api/v1/sector-radar/stocks/000001.SZ/membership?trade_date=2026-08-28",
|
|
{ signal: undefined },
|
|
)
|
|
expect(result).toEqual(membershipPayload)
|
|
})
|
|
|
|
it("keeps the no_data status with a null snapshot date", async () => {
|
|
requestJson.mockResolvedValue({
|
|
...membershipPayload,
|
|
status: "no_data",
|
|
trade_date: null,
|
|
industries: [],
|
|
})
|
|
|
|
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
|
|
|
expect(result.status).toBe("no_data")
|
|
expect(result.trade_date).toBeNull()
|
|
expect(result.industries).toEqual([])
|
|
})
|
|
|
|
it("rejects a membership response with invalid sector references", async () => {
|
|
requestJson.mockResolvedValue({
|
|
...membershipPayload,
|
|
industries: [{ code: "BK0901.DC", name: " " }],
|
|
})
|
|
|
|
await expect(
|
|
getStockSectorMembership("000001.SZ", "2026-08-28"),
|
|
).rejects.toThrow(
|
|
"membership.industries[0].name must be a non-empty string",
|
|
)
|
|
})
|
|
})
|