Develop #35
@@ -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,11 +233,13 @@ 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",
|
||||||
|
(view) => {
|
||||||
|
routeSearch = { ...routeSearch, view }
|
||||||
const ratioPage = {
|
const ratioPage = {
|
||||||
...rankingsResponse,
|
...rankingsResponse,
|
||||||
view: "ratio",
|
view,
|
||||||
rows: rankingsResponse.rows.map((row) => ({
|
rows: rankingsResponse.rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
unit: "ratio",
|
unit: "ratio",
|
||||||
@@ -245,9 +250,29 @@ describe("SectorRadarPage", () => {
|
|||||||
rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }),
|
rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }),
|
||||||
)
|
)
|
||||||
const { rerender } = render(<SectorRadarPage />)
|
const { rerender } = render(<SectorRadarPage />)
|
||||||
expect(screen.getAllByRole("columnheader", { name: "在榜" })).toHaveLength(
|
expect(
|
||||||
2,
|
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)
|
expect(screen.getAllByText("-1%")).toHaveLength(4)
|
||||||
const trigger = screen.getAllByRole("button", {
|
const trigger = screen.getAllByRole("button", {
|
||||||
name: "机器人展开在榜历史",
|
name: "机器人展开在榜历史",
|
||||||
@@ -288,7 +313,50 @@ describe("SectorRadarPage", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0],
|
screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0],
|
||||||
).toHaveAttribute("aria-expanded", "false")
|
).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(<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user