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 = {}, ) { 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() 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() 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() expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument() expect(useSelectionChart).toHaveBeenLastCalledWith( "000001.SZ", "2026-08-08", ) useSelectionChart.mockReturnValue({ data: undefined, isError: true, isPending: false, }) view.rerender() 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() 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() 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() 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() 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() 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 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() 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 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() expect(screen.getByText("结果查询失败")).toBeInTheDocument() }) })