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