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() 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", () => ({ 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, }, ], } 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()) 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.getAllByText("样本有限")[0]).toBeInTheDocument() expect(screen.getAllByText("95%")[0]).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("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() }) })