Develop #35

Merged
sakibcc merged 5 commits from develop into main 2026-09-22 00:03:48 +08:00
2 changed files with 169 additions and 94 deletions
Showing only changes of commit 2f1c4f36c2 - Show all commits
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react" import { fireEvent, render, screen, within } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
import type { import type {
@@ -13,6 +13,7 @@ const navigate = vi.hoisted(() => vi.fn())
const useSectorRadarDates = vi.fn() const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn() const useSectorRadarRankings = vi.fn()
const useSectorRadarHistory = vi.fn() const useSectorRadarHistory = vi.fn()
const useSectorRadarDetail = vi.fn()
const refetchDates = vi.fn() const refetchDates = vi.fn()
const refetchRankings = vi.fn() const refetchRankings = vi.fn()
const fetchNextPage = vi.fn() const fetchNextPage = vi.fn()
@@ -25,6 +26,7 @@ vi.mock("@tanstack/react-router", () => ({
})) }))
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({ vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
useSectorRadarDetail: (...args: unknown[]) => useSectorRadarDetail(...args),
useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args), useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args),
useSectorRadarDates: () => useSectorRadarDates(), useSectorRadarDates: () => useSectorRadarDates(),
useSectorRadarRankings: (...args: unknown[]) => useSectorRadarRankings: (...args: unknown[]) =>
@@ -180,6 +182,7 @@ describe("SectorRadarPage", () => {
isError: false, isError: false,
data: { status: "no_data" }, data: { status: "no_data" },
}) })
useSectorRadarDetail.mockReturnValue({ isPending: true })
navigate.mockReset() navigate.mockReset()
refetchDates.mockReset() refetchDates.mockReset()
refetchRankings.mockReset() refetchRankings.mockReset()
@@ -230,65 +233,130 @@ describe("SectorRadarPage", () => {
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it("mirrors ratio columns and expands only the selected sector history, resetting on date change", () => { it.each(["ratio", "swing"] as const)(
routeSearch = { ...routeSearch, view: "ratio" } "mirrors %s columns and expands only the selected sector history, resetting on date change",
const ratioPage = { (view) => {
...rankingsResponse, routeSearch = { ...routeSearch, view }
view: "ratio", const ratioPage = {
rows: rankingsResponse.rows.map((row) => ({ ...rankingsResponse,
...row, view,
unit: "ratio", rows: rankingsResponse.rows.map((row) => ({
metric_value: -0.01, ...row,
})), unit: "ratio",
} metric_value: -0.01,
useSectorRadarRankings.mockReturnValue( })),
rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }), }
) useSectorRadarRankings.mockReturnValue(
const { rerender } = render(<SectorRadarPage />) rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }),
expect(screen.getAllByRole("columnheader", { name: "在榜" })).toHaveLength( )
2, const { rerender } = render(<SectorRadarPage />)
) expect(
expect(screen.getAllByText("-1%")).toHaveLength(4) screen.getAllByRole("columnheader", { name: "在榜" }),
const trigger = screen.getAllByRole("button", { ).toHaveLength(2)
name: "机器人展开在榜历史", if (view === "swing") {
})[0]! expect(
fireEvent.click(trigger) within(screen.getAllByRole("row")[1]!)
expect(trigger).toHaveAttribute("aria-expanded", "true") .getAllByRole("columnheader")
expect(useSectorRadarHistory).toHaveBeenLastCalledWith({ .map((cell) => cell.textContent),
sectorType: "concept", ).toEqual([
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: [ ])
{ expect(screen.getAllByText("-1.25%")).toHaveLength(2)
...ratioPage, expect(screen.getAllByText("+12.5 亿元")).toHaveLength(2)
publication: { }
...successPublication, expect(screen.getAllByText("-1%")).toHaveLength(4)
publication_id: "next-day", 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",
})),
}, },
rows: ratioPage.rows.map((row) => ({ ],
...row, pageParams: [1],
trade_date: "2026-08-31", },
})), }),
}, )
], rerender(<SectorRadarPage />)
pageParams: [1], expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument()
}, expect(
}), screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0],
) ).toHaveAttribute("aria-expanded", "false")
rerender(<SectorRadarPage />) },
expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument() )
expect(
screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0], it.each(["swing", "ratio", "amount", "rank_change"] as const)(
).toHaveAttribute("aria-expanded", "false") "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(<SectorRadarPage />)
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", () => { it("resets filters to the first batch in router search state", () => {
routeSearch = { ...routeSearch, page: 3 } routeSearch = { ...routeSearch, page: 3 }
@@ -369,8 +369,9 @@ function RadarTable({
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? [] const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
const rowCount = Math.max(topRows.length, bottomRows.length) const rowCount = Math.max(topRows.length, bottomRows.length)
const rankChangeView = response.view === "rank_change" const rankChangeView = response.view === "rank_change"
const dailyView = response.view === "amount" || response.view === "ratio" const metricView = !rankChangeView
const columns = dailyView && response.view === "ratio" ? 10 : 8 const columns =
response.view === "ratio" || response.view === "swing" ? 10 : 8
const loadMoreRequestPending = useRef({ top: false, bottom: false }) const loadMoreRequestPending = useRef({ top: false, bottom: false })
function requestLoadMore(query: RankingQueryResult) { function requestLoadMore(query: RankingQueryResult) {
const side = query === top ? "top" : "bottom" const side = query === top ? "top" : "bottom"
@@ -437,30 +438,30 @@ function RadarTable({
BOTTOM 10% 资金撤离榜 BOTTOM 10% 资金撤离榜
</th> </th>
</tr> </tr>
{dailyView ? ( {metricView ? (
<tr> <tr>
{(response.view === "ratio" {(response.view !== "amount"
? [ ? [
"排名 / 板块", "板块/排名",
"涨跌幅", "涨跌幅",
"净额", "净额",
"在榜", "在榜",
"流入率", response.view === "swing" ? "流入" : "流入率",
"流入率", response.view === "swing" ? "流出" : "流入率",
"在榜", "在榜",
"净额", "净额",
"涨跌幅", "涨跌幅",
"排名 / 板块", "板块/排名",
] ]
: [ : [
"排名 / 板块", "板块/排名",
"涨跌幅", "涨跌幅",
"单日流入率", "单日流入率",
"净额", "净额",
"净额", "净额",
"单日流入率", "单日流入率",
"涨跌幅", "涨跌幅",
"排名 / 板块", "板块/排名",
] ]
).map((label, index) => ( ).map((label, index) => (
<th <th
@@ -476,7 +477,7 @@ function RadarTable({
<> <>
<tr> <tr>
<th className="px-3 py-1 text-left font-medium" scope="col"> <th className="px-3 py-1 text-left font-medium" scope="col">
排名 / 板块 板块/排名
</th> </th>
<th className="px-3 py-1 text-right font-medium" scope="col"> <th className="px-3 py-1 text-right font-medium" scope="col">
排名百分位 排名百分位
@@ -503,7 +504,7 @@ function RadarTable({
排名百分位 排名百分位
</th> </th>
<th className="px-3 py-1 text-right font-medium" scope="col"> <th className="px-3 py-1 text-right font-medium" scope="col">
排名 / 板块 板块/排名
</th> </th>
</tr> </tr>
</> </>
@@ -596,18 +597,18 @@ function RadarMirrorRow({
rankChangeView: boolean rankChangeView: boolean
}) { }) {
const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null) const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null)
if (view === "ratio" || view === "amount") if (view !== "rank_change")
return ( return (
<> <>
<tr className="hover:bg-muted/40"> <tr className="hover:bg-muted/40">
<DailyCells <MetricCells
row={top} row={top}
side="top" side="top"
view={view} view={view}
expanded={expanded === "top"} expanded={expanded === "top"}
onToggle={() => setExpanded(expanded === "top" ? null : "top")} onToggle={() => setExpanded(expanded === "top" ? null : "top")}
/> />
<DailyCells <MetricCells
row={bottom} row={bottom}
side="bottom" side="bottom"
view={view} view={view}
@@ -619,7 +620,10 @@ function RadarMirrorRow({
</tr> </tr>
{expanded && (expanded === "top" ? top : bottom) ? ( {expanded && (expanded === "top" ? top : bottom) ? (
<tr> <tr>
<td colSpan={view === "ratio" ? 10 : 8} className="bg-muted/20 p-3"> <td
colSpan={view !== "amount" ? 10 : 8}
className="bg-muted/20 p-3"
>
<RadarHistory <RadarHistory
query={{ query={{
sectorType: (expanded === "top" ? top : bottom)!.sector_type, sectorType: (expanded === "top" ? top : bottom)!.sector_type,
@@ -647,8 +651,8 @@ function RadarMirrorRow({
</tr> </tr>
) )
} }
/** Mirror daily fields without borrowing values from the opposite sector. */ /** Mirror metric fields without borrowing values from the opposite sector. */
function DailyCells({ function MetricCells({
row, row,
side, side,
view, view,
@@ -657,14 +661,14 @@ function DailyCells({
}: { }: {
row?: RadarRankingRow row?: RadarRankingRow
side: "top" | "bottom" side: "top" | "bottom"
view: "ratio" | "amount" view: RadarMetricKind
expanded: boolean expanded: boolean
onToggle: () => void onToggle: () => void
}) { }) {
if (!row) if (!row)
return ( return (
<td <td
colSpan={view === "ratio" ? 5 : 4} colSpan={view !== "amount" ? 5 : 4}
className="p-3 text-center text-muted-foreground" className="p-3 text-center text-muted-foreground"
> >
— —
@@ -672,10 +676,7 @@ function DailyCells({
) )
const fields = [ const fields = [
<td key="name" className="px-3 py-2 whitespace-nowrap"> <td key="name" className="px-3 py-2 whitespace-nowrap">
<span className="mr-2 text-muted-foreground tabular-nums"> <SectorRank row={row} />
{row.rank_position ?? "—"}
</span>
<RadarDetailDialog row={row} />
</td>, </td>,
<td <td
key="pct" key="pct"
@@ -685,14 +686,14 @@ function DailyCells({
</td>, </td>,
<td <td
key="extra" key="extra"
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio)}`} className={`px-3 py-2 text-right tabular-nums ${radarValueTone(view !== "amount" ? row.daily_net_amount_yuan : row.daily_ratio)}`}
> >
{formatRadarValue( {formatRadarValue(
view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio, view !== "amount" ? row.daily_net_amount_yuan : row.daily_ratio,
view === "ratio" ? "yuan" : "ratio", view !== "amount" ? "yuan" : "ratio",
)} )}
</td>, </td>,
...(view === "ratio" ...(view !== "amount"
? [ ? [
<td key="history" className="px-3 py-2 text-right"> <td key="history" className="px-3 py-2 text-right">
<button <button
@@ -717,6 +718,17 @@ function DailyCells({
return <>{side === "top" ? fields : fields.reverse()}</> return <>{side === "top" ? fields : fields.reverse()}</>
} }
function SectorRank({ row }: { row: RadarRankingRow }) {
return (
<>
<span className="mr-2 text-muted-foreground tabular-nums">
{row.rank_position ?? "—"}
</span>
<RadarDetailDialog row={row} />
</>
)
}
function RankCells({ function RankCells({
row, row,
align, align,
@@ -726,13 +738,8 @@ function RankCells({
}) { }) {
const rankClass = align === "right" ? "text-right" : "text-left" const rankClass = align === "right" ? "text-right" : "text-left"
const rankCell = row ? ( const rankCell = row ? (
<td className={`px-3 py-1.5 ${rankClass} font-semibold tabular-nums`}> <td className={`px-3 py-2 whitespace-nowrap ${rankClass}`}>
{row.rank_position ?? "—"} <SectorRank row={row} />
<span className="mx-0.5 font-normal text-muted-foreground">/</span>
<span className="font-medium text-foreground">{row.sector_name}</span>
<span className="ml-0.5 text-[11px] font-normal text-muted-foreground">
{row.sector_code}
</span>
</td> </td>
) : ( ) : (
<td className="px-3 py-1.5 text-muted-foreground">—</td> <td className="px-3 py-1.5 text-muted-foreground">—</td>