feat(sector-radar): 完成前端排名工作台

This commit is contained in:
yuxuanhui
2026-08-29 20:14:42 +08:00
parent efc4c3d530
commit 8e96e64151
12 changed files with 1913 additions and 11 deletions
@@ -0,0 +1,330 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type {
RadarDatesResponse,
RadarRankingsResponse,
SectorRadarRouteSearch,
} from "../api/sector-radar.types"
import { SectorRadarPage } from "./sector-radar-page"
const navigate = vi.hoisted(() => vi.fn())
const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn()
const refetchDates = vi.fn()
const refetchRankings = vi.fn()
let routeSearch: SectorRadarRouteSearch
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigate,
useSearch: () => routeSearch,
}))
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
useSectorRadarDates: () => useSectorRadarDates(),
useSectorRadarRankings: (...args: unknown[]) =>
useSectorRadarRankings(...args),
}))
const successPublication = {
publication_id: "publication-success",
target_trade_date: "2026-08-28",
status: "success" as const,
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 datesResponse: RadarDatesResponse = {
status: "success",
available_dates: ["2026-08-28", "2026-08-27"],
current_attempt: successPublication,
last_good: successPublication,
}
const rankingsResponse: RadarRankingsResponse = {
status: "success",
requested_trade_date: null,
sector_type: "concept",
view: "amount",
rank_change_metric: "amount",
rank_change_days: 1,
side: "all",
search: null,
publication: successPublication,
definition: {
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
label: "主力净流入(知行独立实现)",
unit: "CNY_100M",
implementation_kind: "independent",
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
},
page: 1,
page_size: 20,
total: 2,
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: 1,
rank_change: 3,
},
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0002.DC",
sector_name: "低空经济",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: null,
quality: "available_limited_sample",
member_count: 4,
valid_sample_count: 3,
membership_coverage: 1,
moneyflow_coverage: 0.75,
rank_position: null,
rank_percentile: null,
rank_change_days: 1,
rank_change: null,
},
],
}
describe("SectorRadarPage", () => {
beforeEach(() => {
routeSearch = {
tradeDate: undefined,
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
search: "",
page: 1,
pageSize: 20,
}
useSectorRadarDates.mockReturnValue({
data: datesResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
navigate.mockReset()
refetchDates.mockReset()
refetchRankings.mockReset()
})
it("renders independent metric provenance, units, quality, and ranking rows", () => {
render(<SectorRadarPage />)
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument()
})
it("stores search and pagination changes in router search state", () => {
render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" },
})
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
) => SectorRadarRouteSearch
expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({
page: 1,
search: "机器人",
})
})
it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
view: "rank_change",
rank_change_days: 5,
rows: rankingsResponse.rows.map((row, index) => ({
...row,
rank_change_days: 5,
rank_change: index === 0 ? 3 : null,
})),
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(
screen.getByRole("columnheader", { name: "5 日排名变化" }),
).toBeInTheDocument()
expect(screen.getByText("+3")).toBeInTheDocument()
expect(screen.getByText("暂无可比历史")).toBeInTheDocument()
})
it("warns when a partial attempt has not replaced last-good", () => {
useSectorRadarDates.mockReturnValue({
data: {
...datesResponse,
current_attempt: {
...successPublication,
publication_id: "publication-partial",
target_trade_date: "2026-08-29",
status: "partial",
coverage: 0.8,
input_hash: null,
},
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("新一期数据不完整")
expect(screen.getByRole("status")).toHaveTextContent(
"当前仍展示最近有效发布 2026-08-28",
)
})
it("keeps the last response visible while React Query refreshes it", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: true,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent(
"当前继续展示上一次已读取的有效结果",
)
expect(screen.getByText("机器人")).toBeInTheDocument()
})
it("keeps stale data visible when a background refresh fails", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument()
})
it("renders loading, error with retry, and no-data states", () => {
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchRankings,
})
const { rerender } = render(<SectorRadarPage />)
expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument()
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(refetchDates).toHaveBeenCalledOnce()
expect(refetchRankings).toHaveBeenCalledOnce()
useSectorRadarDates.mockReturnValue({
data: {
status: "no_data",
available_dates: [],
current_attempt: null,
last_good: null,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
status: "no_data",
publication: null,
rows: [],
total: 0,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
})
})