Files
zhixing-system/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx
T

554 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionResults } from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }),
}))
const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00",
coverage: 1,
eligible_count: 2,
evaluated_count: 2,
error_message: null,
error_type: null,
failed_count: 0,
failures: [],
finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1",
page: 1,
page_size: 5,
run_id: "run-1",
selected_stock_count: 1,
signal_count: 2,
stocks_total: 1,
stocks: [
{
close: 10.5,
name: "平安银行",
signals: [
{
category: "zhixing_b1_original_b1",
close: 10.5,
details: { j: 12, rsi: 20 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
{
category: "zhixing_b1_pullback_white",
close: 10.5,
details: { j: 13 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
status: "success",
strategy: "zhixing_b1",
target_count: 2,
target_trade_date: "2026-08-08",
}
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
mutate,
reset,
})
mutate.mockReset()
reset.mockReset()
routerNavigate.mockReset()
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(screen.getByText("执行完成")).toBeInTheDocument()
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
const table = screen.getByRole("table")
expect(table).toBeInTheDocument()
expect(
within(table).getByRole("columnheader", { name: "信号类型" }),
).toBeInTheDocument()
expect(
within(table).getByRole("columnheader", { name: "J 值" }),
).toBeInTheDocument()
expect(
within(table).queryByRole("columnheader", { name: "关键详情" }),
).not.toBeInTheDocument()
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
expect(within(table).getByText("12")).toBeInTheDocument()
expect(within(table).getByText("13")).toBeInTheDocument()
expect(within(table).getByText("平安银行")).toBeInTheDocument()
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
})
it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 1,
failures: [
{
name: "招商银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "600036.SH",
},
],
status: "partial_success",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
const trigger = screen.getByRole("button", {
name: "查看执行状态:部分成功,未完成评估 1 条",
})
expect(trigger).toHaveAttribute(
"aria-controls",
"selection-execution-status-drawer",
)
expect(trigger).toHaveAttribute("aria-expanded", "false")
fireEvent.click(trigger)
expect(trigger).toHaveAttribute("aria-expanded", "true")
const dialog = await screen.findByRole("dialog", {
name: "知行 B1 执行状态",
})
const summary = within(dialog).getByRole("region", { name: "执行摘要" })
expect(within(summary).getByText("目标股票数")).toBeInTheDocument()
expect(within(summary).getByText("实际参与数")).toBeInTheDocument()
expect(within(summary).getByText("命中股票数")).toBeInTheDocument()
expect(within(summary).getByText("信号条数")).toBeInTheDocument()
expect(within(summary).getByText("数据覆盖率")).toBeInTheDocument()
expect(within(summary).getByText("100.00%")).toBeInTheDocument()
expect(within(summary).getByText("部分成功")).toBeInTheDocument()
const table = within(dialog).getByRole("table")
expect(
within(table).getByRole("columnheader", { name: "股票名称" }),
).toBeInTheDocument()
expect(within(table).getByText("招商银行")).toBeInTheDocument()
expect(within(table).getByText("600036.SH")).toBeInTheDocument()
expect(within(table).getByText("数据读取失败")).toBeInTheDocument()
expect(within(table).getByText("目标日数据读取失败")).toBeInTheDocument()
expect(
screen.queryByRole("heading", { name: "未完成评估" }),
).not.toBeInTheDocument()
fireEvent.click(
within(dialog).getByRole("button", { name: "关闭执行状态" }),
)
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
expect(trigger).toHaveAttribute("aria-expanded", "false")
expect(document.activeElement).toBe(trigger)
})
it("shows an empty evaluation table when there are no failures", async () => {
render(<SelectionResultsPage />)
fireEvent.click(
screen.getByRole("button", {
name: "查看执行状态:执行完成,未完成评估 0 条",
}),
)
const dialog = await screen.findByRole("dialog", {
name: "知行 B1 执行状态",
})
expect(within(dialog).getByRole("table")).toBeInTheDocument()
expect(within(dialog).getByText("暂无未完成评估")).toBeInTheDocument()
fireEvent.keyDown(dialog, { key: "Escape" })
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
expect(
screen.getByRole("button", {
name: "查看执行状态:执行完成,未完成评估 0 条",
}),
).toHaveFocus()
})
it("keeps execution status accessible when no signals are hit", async () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
signal_count: 0,
stocks: [],
stocks_total: 0,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
const trigger = screen.getByRole("button", {
name: "查看执行状态:执行完成,未完成评估 0 条",
})
fireEvent.click(trigger)
const dialog = await screen.findByRole("dialog", {
name: "知行 B1 执行状态",
})
expect(within(dialog).getByText("信号条数")).toBeInTheDocument()
})
it("keeps execution status accessible for a failed result", async () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 2,
failures: [
{
name: "招商银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "600036.SZ",
},
{
name: "宁德时代",
reason: "指标计算失败",
status: "calculation_error",
ts_code: "300750.SZ",
},
],
signal_count: 0,
stocks: [],
status: "failed",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.click(
screen.getByRole("button", {
name: "查看执行状态:执行失败,未完成评估 2 条",
}),
)
const dialog = await screen.findByRole("dialog", {
name: "知行 B1 执行状态",
})
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
})
it("uses shared selects and resets pagination when the category changes", async () => {
render(<SelectionResultsPage />)
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
"知行 B1",
)
const categoryTrigger = screen.getByRole("combobox", {
name: "筛选信号分类",
})
expect(categoryTrigger).toHaveTextContent("全部信号")
fireEvent.click(categoryTrigger)
const oversoldOption = await screen.findByRole("option", { name: "超卖类" })
const content = oversoldOption.closest('[data-slot="select-content"]')
expect(content).toHaveAttribute("data-align-trigger", "false")
expect(content?.parentElement).toHaveAttribute("data-align", "start")
fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" })
fireEvent.click(oversoldOption)
expect(routerNavigate).toHaveBeenCalled()
const lastCall =
routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1]
const searchUpdate = lastCall[0].search as (previous: {
category: string
page: number
pageSize: number
search: string
}) => Record<string, unknown>
expect(
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
).toEqual({
category: "oversold",
page: 1,
pageSize: 5,
search: "",
})
})
it("filters signals, updates the detail panel, and expands mobile details", () => {
const extraStock = {
...selectedResult.stocks[0],
name: "招商银行",
signals: [
{
...selectedResult.stocks[0].signals[0],
category: "zhixing_b1_oversold_volume",
details: { macd: 3.2 },
name: "招商银行",
ts_code: "600036.SH",
},
],
ts_code: "600036.SH",
}
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
signal_count: 3,
stocks: [...selectedResult.stocks, extraStock],
stocks_total: 2,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
target: { value: "招商" },
})
const table = screen.getByRole("table")
expect(within(table).getByText("招商银行")).toBeInTheDocument()
fireEvent.click(within(table).getByText("招商银行"))
expect(
within(
screen.getByRole("complementary", { name: "当前股票详情" }),
).getByText("招商银行"),
).toBeInTheDocument()
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
fireEvent.click(expandButton)
expect(expandButton).toHaveAttribute("aria-expanded", "true")
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
expect(screen.getAllByText("3.2").length).toBeGreaterThan(1)
})
it("navigates to another page when the result list is paginated", () => {
const stocks = Array.from({ length: 6 }, (_, index) => ({
...selectedResult.stocks[0],
name: `股票${index + 1}`,
signals: [
{
...selectedResult.stocks[0].signals[0],
name: `股票${index + 1}`,
ts_code: `00000${index + 1}.SZ`,
},
],
ts_code: `00000${index + 1}.SZ`,
}))
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
selected_stock_count: stocks.length,
signal_count: stocks.length,
stocks,
stocks_total: stocks.length,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
expect(routerNavigate).toHaveBeenCalled()
})
it("requires confirmation before a rerun and does not trigger on cancel", () => {
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
const dialog = screen.getByRole("dialog", { name: "确认重新执行策略?" })
expect(within(dialog).getByText(/清空 2026-08-08/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "取消" }))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
fireEvent.click(
within(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).getByRole("button", { name: "确认重新执行" }),
)
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: true,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("uses the same confirmation flow for a failed retry", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 1,
failures: [
{
name: "平安银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "000001.SZ",
},
],
selected_stock_count: 0,
signal_count: 0,
stocks: [],
status: "failed",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重试执行" }))
expect(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).toBeInTheDocument()
})
it("renders a no-data state and triggers an initial run after date selection", async () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
run_id: null,
stocks: [],
status: "no_data",
target_trade_date: "2026-08-08",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "目标交易日" }))
const calendar = await screen.findByRole("grid")
const dateButton = within(calendar).getByRole("button", {
name: "2026年8月9日 星期日",
})
fireEvent.click(dateButton)
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: false,
strategy: "zhixing_b1",
target_trade_date: "2026-08-09",
},
},
expect.any(Object),
)
})
it("keeps the target trade date empty after clearing the date picker", async () => {
render(<SelectionResultsPage />)
const dateTrigger = screen.getByRole("button", { name: "目标交易日" })
fireEvent.click(dateTrigger)
const calendar = await screen.findByRole("grid")
fireEvent.click(
within(calendar).getByRole("button", {
name: "2026年8月8日 星期六,已选择",
}),
)
expect(dateTrigger).toHaveTextContent("选择日期")
})
it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({
data: { ...selectedResult, stocks: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: { ...selectedResult, stocks: [], status: "running" },
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1,
pageSize: 5,
})
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
})
})