feat(web): 选股列表改为滚动加载并精简操作区
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,13 +21,20 @@ 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", () => ({
|
||||
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
||||
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||
}))
|
||||
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,
|
||||
@@ -110,6 +117,34 @@ const selectedResult: SelectionResults = {
|
||||
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({
|
||||
@@ -117,16 +152,8 @@ describe("SelectionResultsPage", () => {
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: selectedResult,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionRun.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
||||
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
||||
useTriggerSelectionRun.mockReturnValue({
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -136,6 +163,8 @@ describe("SelectionResultsPage", () => {
|
||||
mutate.mockReset()
|
||||
reset.mockReset()
|
||||
routerNavigate.mockReset()
|
||||
fetchNextPage.mockReset()
|
||||
fetchNextPage.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it("renders persisted results and keeps multiple categories visible", () => {
|
||||
@@ -155,6 +184,18 @@ describe("SelectionResultsPage", () => {
|
||||
expect(within(record).getByText("¥ 10.50")).toBeInTheDocument()
|
||||
expect(within(record).getByText("华纳药厂")).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: "当前股票详情",
|
||||
@@ -224,14 +265,12 @@ describe("SelectionResultsPage", () => {
|
||||
])(
|
||||
"keeps selection signals visible when score state changes",
|
||||
({ expected, score }) => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
stocks: [{ ...selectedResult.stocks[0], score }],
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -285,8 +324,8 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
failed_count: 1,
|
||||
failures: [
|
||||
@@ -298,10 +337,8 @@ describe("SelectionResultsPage", () => {
|
||||
},
|
||||
],
|
||||
status: "partial_success",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -376,16 +413,14 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("keeps execution status accessible when no signals are hit", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
signal_count: 0,
|
||||
stocks: [],
|
||||
stocks_total: 0,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -401,8 +436,8 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("keeps execution status accessible for a failed result", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
failed_count: 2,
|
||||
failures: [
|
||||
@@ -422,10 +457,8 @@ describe("SelectionResultsPage", () => {
|
||||
signal_count: 0,
|
||||
stocks: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -440,7 +473,7 @@ describe("SelectionResultsPage", () => {
|
||||
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("uses shared selects and resets pagination when the category changes", async () => {
|
||||
it("uses shared selects when the category changes", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
|
||||
@@ -472,13 +505,13 @@ describe("SelectionResultsPage", () => {
|
||||
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
|
||||
).toEqual({
|
||||
category: "oversold",
|
||||
page: 1,
|
||||
page: 4,
|
||||
pageSize: 5,
|
||||
search: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("requests database-backed score sorting and resets the page", async () => {
|
||||
it("requests database-backed score sorting", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const sortTrigger = screen.getByRole("combobox", {
|
||||
@@ -507,14 +540,14 @@ describe("SelectionResultsPage", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
category: "all",
|
||||
page: 1,
|
||||
page: 3,
|
||||
pageSize: 5,
|
||||
search: "",
|
||||
sort: "score_desc",
|
||||
})
|
||||
})
|
||||
|
||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||
it("filters signals and updates the detail panel", () => {
|
||||
const extraStock = {
|
||||
...selectedResult.stocks[0],
|
||||
name: "招商银行",
|
||||
@@ -529,16 +562,14 @@ describe("SelectionResultsPage", () => {
|
||||
],
|
||||
ts_code: "600036.SH",
|
||||
}
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
signal_count: 3,
|
||||
stocks: [...selectedResult.stocks, extraStock],
|
||||
stocks_total: 2,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -555,24 +586,19 @@ describe("SelectionResultsPage", () => {
|
||||
const selectButton = within(extraRecord).getByRole("button", {
|
||||
name: /招商银行/,
|
||||
})
|
||||
fireEvent.keyDown(selectButton, { key: "Enter" })
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(selectButton, { key: " " })
|
||||
const expandButton = within(extraRecord).getByRole("button", {
|
||||
name: "查看详情",
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
})
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||
expect(screen.getAllByText("3.2").length).toBeGreaterThan(1)
|
||||
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("navigates to another page when the result list is paginated", () => {
|
||||
it("shows the full result list without pagination controls", () => {
|
||||
const stocks = Array.from({ length: 6 }, (_, index) => ({
|
||||
...selectedResult.stocks[0],
|
||||
name: `股票${index + 1}`,
|
||||
@@ -585,23 +611,121 @@ describe("SelectionResultsPage", () => {
|
||||
],
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
}))
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...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()
|
||||
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", () => {
|
||||
@@ -634,8 +758,8 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("uses the same confirmation flow for a failed retry", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
failed_count: 1,
|
||||
failures: [
|
||||
@@ -650,10 +774,8 @@ describe("SelectionResultsPage", () => {
|
||||
signal_count: 0,
|
||||
stocks: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -664,17 +786,15 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("renders a no-data state and triggers an initial run after date selection", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
useSelectionResults.mockReturnValue(
|
||||
selectionQueryResult({
|
||||
...selectedResult,
|
||||
run_id: null,
|
||||
stocks: [],
|
||||
status: "no_data",
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
@@ -715,33 +835,35 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
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", {
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
pageSize: 20,
|
||||
sort: "code",
|
||||
})
|
||||
})
|
||||
|
||||
it("renders query failures explicitly", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: undefined,
|
||||
...emptySelectionQuery(),
|
||||
isError: true,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
Reference in New Issue
Block a user