Files
zhixing-system/zhixing-web/src/features/sector-radar/api/sector-radar.api.test.ts
T
yuxuanhui 07c5b25043 refactor(selection): drop residual concept-sector surface from sub-industry path
- 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
2026-09-06 16:15:08 +08:00

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",
)
})
})