import { fireEvent, render, screen, within } 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 useSectorRadarHistory = vi.fn() const useSectorRadarDetail = vi.fn() const refetchDates = vi.fn() const refetchRankings = vi.fn() const fetchNextPage = vi.fn() let routeSearch: SectorRadarRouteSearch vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigate, useSearch: () => routeSearch, })) vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ useSectorRadarDetail: (...args: unknown[]) => useSectorRadarDetail(...args), useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args), 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, pct_change: -1.25, daily_net_amount_yuan: 1250000000, daily_ratio: -0.02, on_list_count: 3, history_available_days: 10, }, { 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, pct_change: null, daily_net_amount_yuan: null, daily_ratio: null, on_list_count: null, history_available_days: 0, }, ], } const bottomRankingsResponse: RadarRankingsResponse = { ...rankingsResponse, side: "bottom", rows: rankingsResponse.rows.map((row, index) => ({ ...row, sector_code: `BOTTOM-${index + 1}`, sector_name: index === 0 ? "新能源" : "医药商业", metric_value: index === 0 ? -8.4 : null, rank_position: index + 1, })), } function rankingQueryResult(overrides: Record = {}) { return { data: { pageParams: [1], pages: [rankingsResponse] }, fetchNextPage, hasNextPage: false, isError: false, isFetchNextPageError: false, isFetching: false, isFetchingNextPage: false, isPending: false, refetch: refetchRankings, ...overrides, } } 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(rankingQueryResult()) useSectorRadarHistory.mockReturnValue({ isPending: false, isError: false, data: { status: "no_data" }, }) useSectorRadarDetail.mockReturnValue({ isPending: true }) navigate.mockReset() refetchDates.mockReset() refetchRankings.mockReset() fetchNextPage.mockReset() fetchNextPage.mockResolvedValue(undefined) }) it("renders ranking rows without the description card or pagination", () => { render() expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.queryByText("19/20")).not.toBeInTheDocument() expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument() expect( screen.queryByRole("columnheader", { name: "排名百分位" }), ).not.toBeInTheDocument() expect( screen.queryByRole("columnheader", { name: "样本" }), ).not.toBeInTheDocument() expect( screen.getAllByRole("columnheader", { name: "涨跌幅" }), ).toHaveLength(2) expect( screen.getAllByRole("columnheader", { name: "单日流入率" }), ).toHaveLength(2) expect(screen.getAllByText("-2%")).toHaveLength(2) expect( screen.queryByRole("columnheader", { name: "资金覆盖率" }), ).not.toBeInTheDocument() expect( screen.queryByRole("columnheader", { name: "质量" }), ).not.toBeInTheDocument() expect(screen.queryByText("样本有限")).not.toBeInTheDocument() expect(screen.queryByText("95%")).not.toBeInTheDocument() expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument() expect( screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"), ).not.toBeInTheDocument() expect( screen.queryByText("zhixing_amount_net_bn_v1"), ).not.toBeInTheDocument() expect( screen.queryByRole("button", { name: "第 1 页" }), ).not.toBeInTheDocument() expect( screen.queryByRole("combobox", { name: "每页数量" }), ).not.toBeInTheDocument() }) it.each(["ratio", "swing"] as const)( "mirrors %s columns and expands only the selected sector history, resetting on date change", (view) => { routeSearch = { ...routeSearch, view } const ratioPage = { ...rankingsResponse, view, rows: rankingsResponse.rows.map((row) => ({ ...row, unit: "ratio", metric_value: -0.01, })), } useSectorRadarRankings.mockReturnValue( rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }), ) const { rerender } = render() expect( screen.getAllByRole("columnheader", { name: "在榜" }), ).toHaveLength(2) if (view === "swing") { expect( within(screen.getAllByRole("row")[1]!) .getAllByRole("columnheader") .map((cell) => cell.textContent), ).toEqual([ "板块/排名", "涨跌幅", "净额", "在榜", "流入", "流出", "在榜", "净额", "涨跌幅", "板块/排名", ]) expect(screen.getAllByText("-1.25%")).toHaveLength(2) expect(screen.getAllByText("+12.5 亿元")).toHaveLength(2) } expect(screen.getAllByText("-1%")).toHaveLength(4) const trigger = screen.getAllByRole("button", { name: "机器人展开在榜历史", })[0]! fireEvent.click(trigger) expect(trigger).toHaveAttribute("aria-expanded", "true") expect(useSectorRadarHistory).toHaveBeenLastCalledWith({ sectorType: "concept", sectorCode: "BK0001.DC", tradeDate: "2026-08-28", }) expect(screen.getByText("暂无在榜历史数据。")).toBeInTheDocument() fireEvent.click(trigger) expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() fireEvent.click(trigger) useSectorRadarRankings.mockReturnValue( rankingQueryResult({ data: { pages: [ { ...ratioPage, publication: { ...successPublication, publication_id: "next-day", }, rows: ratioPage.rows.map((row) => ({ ...row, trade_date: "2026-08-31", })), }, ], pageParams: [1], }, }), ) rerender() expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() expect( screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0], ).toHaveAttribute("aria-expanded", "false") }, ) it.each(["swing", "ratio", "amount", "rank_change"] as const)( "opens sector details from both sides of the %s list", async (view) => { routeSearch = { ...routeSearch, view } useSectorRadarRankings.mockImplementation((query: { side: string }) => rankingQueryResult({ data: { pageParams: [1], pages: [ { ...(query.side === "top" ? rankingsResponse : bottomRankingsResponse), view, }, ], }, }), ) render() expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument() for (const [name, code] of [ ["机器人", "BK0001.DC"], ["新能源", "BOTTOM-1"], ]) { const trigger = screen.getByRole("button", { name }) expect(trigger.closest("td")).toHaveTextContent(`1${name}`) fireEvent.click(trigger) const dialog = await screen.findByRole("dialog") expect( within(dialog).getByRole("heading", { name }), ).toBeInTheDocument() expect(useSectorRadarDetail).toHaveBeenLastCalledWith({ sectorType: "concept", sectorCode: code, tradeDate: "2026-08-28", }) fireEvent.click(within(dialog).getByRole("button", { name: "Close" })) } }, ) it("resets filters to the first batch in router search state", () => { routeSearch = { ...routeSearch, page: 3 } render() fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { target: { value: "机器人" }, }) const searchCall = navigate.mock.calls[0]?.[0].search as ( previous: SectorRadarRouteSearch, ) => SectorRadarRouteSearch expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({ page: 1, search: "机器人", }) }) it("renders accessible tabs, writes tab state to the URL, and queries both sides", () => { render() expect( screen.getByRole("tablist", { name: "板块类型" }), ).toBeInTheDocument() expect( screen.getByRole("tablist", { name: "指标视角" }), ).toBeInTheDocument() expect(screen.getByRole("tab", { name: "单日净额" })).toHaveAttribute( "aria-selected", "true", ) expect(useSectorRadarRankings).toHaveBeenNthCalledWith( 1, expect.objectContaining({ side: "top", view: "amount" }), ) expect(useSectorRadarRankings).toHaveBeenNthCalledWith( 2, expect.objectContaining({ side: "bottom", view: "amount" }), ) fireEvent.click(screen.getByRole("tab", { name: "行业" })) const searchCall = navigate.mock.calls[0]?.[0].search as ( previous: SectorRadarRouteSearch, ) => SectorRadarRouteSearch expect(searchCall({ ...routeSearch, page: 4 })).toMatchObject({ page: 1, sectorType: "industry", }) }) it("loads the next page once when repeated scroll events reach the bottom", () => { fetchNextPage.mockReturnValue(new Promise(() => undefined)) useSectorRadarRankings.mockReturnValue( rankingQueryResult({ hasNextPage: true }), ) render() const scrollRegion = screen.getByRole("region", { name: "板块资金排名滚动区域", }) Object.defineProperties(scrollRegion, { clientHeight: { configurable: true, value: 300 }, scrollHeight: { configurable: true, value: 1_000 }, scrollTop: { configurable: true, value: 550 }, }) fireEvent.scroll(scrollRegion) fireEvent.scroll(scrollRegion) expect(fetchNextPage).toHaveBeenCalledOnce() }) it("announces that the next page is loading without hiding loaded rows", () => { useSectorRadarRankings.mockReturnValue( rankingQueryResult({ hasNextPage: true, isFetching: true, isFetchingNextPage: true, }), ) render() expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块") expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument() }) it("merges rows from every loaded page", () => { const firstPage = { ...rankingsResponse, total: 3 } const nextPage = { ...rankingsResponse, page: 2, total: 3, rows: [ { ...rankingsResponse.rows[0], sector_code: "BK0003.DC", sector_name: "商业航天", rank_position: 3, }, ], } useSectorRadarRankings.mockReturnValue( rankingQueryResult({ data: { pageParams: [1, 2], pages: [firstPage, nextPage] }, }), ) render() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.getAllByText("低空经济")[0]).toBeInTheDocument() expect(screen.getAllByText("商业航天")[0]).toBeInTheDocument() }) it("keeps loaded rows and offers retry when loading more fails", () => { useSectorRadarRankings.mockReturnValue( rankingQueryResult({ hasNextPage: true, isError: true, isFetchNextPageError: true, }), ) render() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.getByRole("alert")).toHaveTextContent( "强榜加载更多失败,弱榜加载更多失败,已加载结果仍保留", ) expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "重试强榜" })) expect(fetchNextPage).toHaveBeenCalledOnce() }) it("keeps the healthy side visible when only the other side fails to load more", () => { const topFetchNextPage = vi.fn().mockResolvedValue(undefined) const bottomFetchNextPage = vi.fn().mockResolvedValue(undefined) useSectorRadarRankings.mockImplementation((query: { side: string }) => rankingQueryResult({ data: { pageParams: [1], pages: [ query.side === "bottom" ? bottomRankingsResponse : rankingsResponse, ], }, fetchNextPage: query.side === "bottom" ? bottomFetchNextPage : topFetchNextPage, isFetchNextPageError: query.side === "bottom", hasNextPage: true, }), ) render() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.getByText("新能源")).toBeInTheDocument() expect(screen.getByRole("alert")).toHaveTextContent("弱榜加载更多失败") expect(screen.getByRole("button", { name: "重试弱榜" })).toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "重试弱榜" })) expect(bottomFetchNextPage).toHaveBeenCalledOnce() expect(topFetchNextPage).not.toHaveBeenCalled() }) it("shows rank changes without inventing missing history", () => { routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } useSectorRadarRankings.mockReturnValue( rankingQueryResult({ data: { pageParams: [1], pages: [ { ...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, })), }, ], }, }), ) render() expect( screen.getByRole("columnheader", { name: "排名上升" }), ).toBeInTheDocument() expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument() expect(screen.getAllByText("暂无可比历史")[0]).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() 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( rankingQueryResult({ isFetching: true }), ) render() expect(screen.getByRole("status")).toHaveTextContent( "当前继续展示上一次已读取的有效结果", ) expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() }) it("keeps stale data visible when a background refresh fails", () => { useSectorRadarRankings.mockReturnValue( rankingQueryResult({ isError: true }), ) render() expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败") expect(screen.getAllByText("机器人")[0]).toBeInTheDocument() expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument() }) it("shows a fatal error when one side has no data and the other has no response", () => { useSectorRadarRankings.mockImplementation((query: { side: string }) => rankingQueryResult( query.side === "top" ? { data: { pageParams: [1], pages: [ { ...rankingsResponse, side: "top", status: "no_data", publication: null, rows: [], total: 0, }, ], }, } : { data: undefined, isError: true }, ), ) render() expect(screen.getByText("板块资金雷达暂时不可用")).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, fetchNextPage, hasNextPage: false, isError: false, isFetchNextPageError: false, isFetching: true, isFetchingNextPage: false, isPending: true, refetch: refetchRankings, }) const { rerender } = render() expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument() useSectorRadarDates.mockReturnValue({ data: undefined, isError: true, isFetching: false, isPending: false, refetch: refetchDates, }) useSectorRadarRankings.mockReturnValue({ data: undefined, fetchNextPage, hasNextPage: false, isError: true, isFetchNextPageError: false, isFetching: false, isFetchingNextPage: false, isPending: false, refetch: refetchRankings, }) rerender() fireEvent.click(screen.getByRole("button", { name: "重试" })) expect(refetchDates).toHaveBeenCalledOnce() expect(refetchRankings).toHaveBeenCalledTimes(2) useSectorRadarDates.mockReturnValue({ data: { status: "no_data", available_dates: [], current_attempt: null, last_good: null, }, isError: false, isFetching: false, isPending: false, refetch: refetchDates, }) useSectorRadarRankings.mockReturnValue( rankingQueryResult({ data: { pageParams: [1], pages: [ { ...rankingsResponse, status: "no_data", publication: null, rows: [], total: 0, }, ], }, }), ) rerender() expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() }) })