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

873 lines
26 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 {
SelectionPatternScore,
SelectionResults,
} from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionChart = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
const fetchNextPage = vi.fn()
vi.mock("@/features/selection/api/selection.query", async () => {
const actual = await vi.importActual<
typeof import("@/features/selection/api/selection.query")
>("@/features/selection/api/selection.query")
return {
...actual,
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}
})
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({
page: 1,
pageSize: 5,
search: "",
category: "all",
sort: "code",
}),
}))
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: "平安银行",
score: {
breakdown: {
kdj_state: 83,
price_shape: 90.1,
trend_structure: 71.2,
volume_pattern: 88,
},
case: {
breakout_date: "2025-05-12",
id: "case_001",
name: "华纳药厂",
},
reason: null,
status: "matched",
threshold: 60,
value: 86.4,
version: "zhixing_b1_pattern_fastdtw_v1",
},
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",
}
function emptySelectionQuery() {
return {
data: undefined,
fetchNextPage,
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
}
}
function selectionQueryResult(
result: SelectionResults,
overrides: Record<string, unknown> = {},
) {
return {
data: { pageParams: [1], pages: [result] },
fetchNextPage,
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetchingNextPage: false,
isPending: false,
...overrides,
}
}
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
useSelectionRun.mockReturnValue(emptySelectionQuery())
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
mutate,
reset,
})
mutate.mockReset()
reset.mockReset()
routerNavigate.mockReset()
fetchNextPage.mockReset()
fetchNextPage.mockResolvedValue(undefined)
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(screen.getByText("执行完成")).toBeInTheDocument()
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
expect(screen.queryByRole("table")).not.toBeInTheDocument()
const record = screen.getByRole("article")
expect(within(record).getByText("平安银行")).toBeInTheDocument()
expect(within(record).getByText("000001.SZ")).toBeInTheDocument()
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
expect(within(record).getByText("回踩白线 B")).toBeInTheDocument()
expect(within(record).getByText("86.40 分")).toBeInTheDocument()
expect(within(record).getByText("12")).toBeInTheDocument()
expect(within(record).queryByText("13")).not.toBeInTheDocument()
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "查看详情" }),
).not.toBeInTheDocument()
expect(screen.queryByText(/共 \d+ 条/)).not.toBeInTheDocument()
const rerunButton = screen.getByRole("button", { name: "重新执行" })
const statusTrigger = screen.getByRole("button", {
name: "查看执行状态:执行完成,未完成评估 0 条",
})
expect(
rerunButton.compareDocumentPosition(statusTrigger) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
expect(
within(detailPanel).getByText("暂无可用行情数据。"),
).toBeInTheDocument()
expect(
within(detailPanel).getByText("最佳案例:华纳药厂"),
).toBeInTheDocument()
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
const caseImages = within(detailPanel).getAllByRole("img", {
name: "华纳药厂 B1 参考案例图",
})
expect(caseImages).toHaveLength(1)
expect(caseImages[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
)
})
it.each<{
expected: string
score: SelectionPatternScore | null
}>([
{
expected: "本次运行未执行图形评分。",
score: null,
},
{
expected: "低于匹配阈值",
score: {
breakdown: {
kdj_state: 42,
price_shape: 44,
trend_structure: 40,
volume_pattern: 43,
},
case: {
breakout_date: "2025-07-23",
id: "case_004",
name: "方正科技",
},
reason: null,
status: "below_threshold",
threshold: 60,
value: 42.5,
version: "zhixing_b1_pattern_fastdtw_v1",
},
},
{
expected: "评分暂不可用",
score: {
breakdown: null,
case: null,
reason: "FastDTW unavailable",
status: "failed",
threshold: null,
value: null,
version: null,
},
},
])(
"keeps selection signals visible when score state changes",
({ expected, score }) => {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
stocks: [{ ...selectedResult.stocks[0], score }],
}),
)
render(<SelectionResultsPage />)
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
const record = screen.getByRole("article")
expect(within(record).getByText("原始 B1")).toBeInTheDocument()
expect(within(record).getByText("12")).toBeInTheDocument()
if (score?.status === "below_threshold") {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
const images = within(detailPanel).getAllByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
expect(within(detailPanel).getAllByText("42.50 分")).toHaveLength(2)
} else {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).queryByRole("img")).not.toBeInTheDocument()
}
},
)
it("shows chart loading and error states for the selected stock", () => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: true,
})
const view = render(<SelectionResultsPage />)
expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument()
expect(useSelectionChart).toHaveBeenLastCalledWith(
"000001.SZ",
"2026-08-08",
)
useSelectionChart.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
view.rerender(<SelectionResultsPage />)
expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument()
})
it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue(
selectionQueryResult({
...selectedResult,
failed_count: 1,
failures: [
{
name: "招商银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "600036.SH",
},
],
status: "partial_success",
}),
)
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(
selectionQueryResult({
...selectedResult,
signal_count: 0,
stocks: [],
stocks_total: 0,
}),
)
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(
selectionQueryResult({
...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",
}),
)
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 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: 4,
pageSize: 5,
search: "",
})
})
it("requests database-backed score sorting", async () => {
render(<SelectionResultsPage />)
const sortTrigger = screen.getByRole("combobox", {
name: "排序命中股票",
})
fireEvent.click(sortTrigger)
const option = await screen.findByRole("option", { name: "评分从高到低" })
fireEvent.pointerDown(option, { pointerType: "mouse" })
fireEvent.click(option)
const lastCall = routerNavigate.mock.calls.at(-1)
const searchUpdate = lastCall?.[0].search as (previous: {
category: string
page: number
pageSize: number
search: string
sort: string
}) => Record<string, unknown>
expect(
searchUpdate({
category: "all",
page: 3,
pageSize: 5,
search: "",
sort: "code",
}),
).toEqual({
category: "all",
page: 3,
pageSize: 5,
search: "",
sort: "score_desc",
})
})
it("filters signals and updates the detail panel", () => {
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(
selectionQueryResult({
...selectedResult,
signal_count: 3,
stocks: [...selectedResult.stocks, extraStock],
stocks_total: 2,
}),
)
render(<SelectionResultsPage />)
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
target: { value: "招商" },
})
const extraRecord = screen
.getAllByRole("article")
.find((article) => within(article).queryByText("招商银行"))
expect(extraRecord).toBeTruthy()
if (!extraRecord) {
throw new Error("expected 招商银行 card to be visible")
}
const selectButton = within(extraRecord).getByRole("button", {
name: /招商银行/,
})
fireEvent.keyDown(selectButton, { key: " " })
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).getByText("招商银行")).toBeInTheDocument()
expect(within(detailPanel).getByText("MACD")).toBeInTheDocument()
expect(within(detailPanel).getByText("3.2")).toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "查看详情" }),
).not.toBeInTheDocument()
})
it("shows the full result list without pagination controls", () => {
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(
selectionQueryResult({
...selectedResult,
selected_stock_count: stocks.length,
signal_count: stocks.length,
stocks,
stocks_total: stocks.length,
}),
)
render(<SelectionResultsPage />)
expect(screen.getAllByText("股票1").length).toBeGreaterThan(0)
expect(screen.getByText("股票6")).toBeInTheDocument()
expect(screen.queryByText("第 1–5 条,共 6 条")).not.toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "第 2 页" }),
).not.toBeInTheDocument()
expect(
screen.queryByRole("combobox", { name: "每页数量" }),
).not.toBeInTheDocument()
})
it("loads the next page once when repeated scroll events reach the bottom", () => {
fetchNextPage.mockReturnValue(new Promise(() => undefined))
useSelectionResults.mockReturnValue(
selectionQueryResult(
{ ...selectedResult, stocks_total: 40 },
{ hasNextPage: true },
),
)
render(<SelectionResultsPage />)
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", () => {
useSelectionResults.mockReturnValue(
selectionQueryResult(selectedResult, {
hasNextPage: true,
isFetchingNextPage: true,
}),
)
render(<SelectionResultsPage />)
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多股票")
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
})
it("keeps loaded rows and offers retry when loading more fails", () => {
useSelectionResults.mockReturnValue(
selectionQueryResult(selectedResult, {
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
}),
)
render(<SelectionResultsPage />)
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
expect(screen.getByRole("alert")).toHaveTextContent(
"加载更多失败,已加载结果仍保留",
)
fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it("merges stocks from every loaded page", () => {
const nextStock = {
...selectedResult.stocks[0],
name: "招商银行",
ts_code: "600036.SH",
signals: [
{
...selectedResult.stocks[0].signals[0],
name: "招商银行",
ts_code: "600036.SH",
},
],
}
useSelectionResults.mockReturnValue(
selectionQueryResult(selectedResult, {
data: {
pageParams: [1, 2],
pages: [
{ ...selectedResult, stocks_total: 2 },
{
...selectedResult,
page: 2,
stocks: [nextStock],
stocks_total: 2,
},
],
},
}),
)
render(<SelectionResultsPage />)
expect(screen.getAllByText("平安银行").length).toBeGreaterThan(0)
expect(screen.getAllByText("招商银行").length).toBeGreaterThan(0)
})
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(
selectionQueryResult({
...selectedResult,
failed_count: 1,
failures: [
{
name: "平安银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "000001.SZ",
},
],
selected_stock_count: 0,
signal_count: 0,
stocks: [],
status: "failed",
}),
)
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(
selectionQueryResult({
...selectedResult,
run_id: null,
stocks: [],
status: "no_data",
target_trade_date: "2026-08-08",
}),
)
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(
selectionQueryResult({
...selectedResult,
stocks: [],
status: "running",
}),
)
useSelectionRun.mockReturnValue(
selectionQueryResult({
...selectedResult,
stocks: [],
status: "running",
}),
)
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
pageSize: 20,
sort: "code",
})
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
...emptySelectionQuery(),
isError: true,
})
render(<SelectionResultsPage />)
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
})
})