refactor: 统一管理页面布局 — FilterToolbar + usePageSearchParams + parseListParams
This commit is contained in:
106
tests/web/FilterToolbar.test.tsx
Normal file
106
tests/web/FilterToolbar.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { FilterToolbar } from "../../src/web/shared/components/FilterToolbar";
|
||||
import { renderWithProviders } from "./test-utils";
|
||||
|
||||
describe("FilterToolbar", () => {
|
||||
it("renders filter Select with placeholder", () => {
|
||||
const { getByText } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
filters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
onChange: () => {},
|
||||
options: [
|
||||
{ label: "进行中", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
],
|
||||
placeholder: "选择状态",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(getByText("选择状态")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders search input with placeholder", () => {
|
||||
const { getByPlaceholderText } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
search: {
|
||||
onReset: () => {},
|
||||
onSearch: () => {},
|
||||
placeholder: "搜索名称",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(getByPlaceholderText("搜索名称")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders reset button", () => {
|
||||
const { container } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
search: {
|
||||
onReset: () => {},
|
||||
onSearch: () => {},
|
||||
placeholder: "搜索",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const resetBtn = container.querySelector('button[title="重置"]');
|
||||
expect(resetBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders action buttons", () => {
|
||||
const { container } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
actions: createElement("button", null, "新建项目"),
|
||||
search: {
|
||||
onReset: () => {},
|
||||
onSearch: () => {},
|
||||
placeholder: "搜索",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const buttons = container.querySelectorAll("button");
|
||||
const hasAction = Array.from(buttons).some((btn) => btn.textContent?.includes("新建项目"));
|
||||
expect(hasAction).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onSearch when search entered after typing", () => {
|
||||
const onSearch = vi.fn();
|
||||
const onReset = vi.fn();
|
||||
const { getByPlaceholderText } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
search: {
|
||||
keyword: "",
|
||||
onReset,
|
||||
onSearch,
|
||||
placeholder: "搜索名称",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const input = getByPlaceholderText("搜索名称");
|
||||
fireEvent.change(input, { target: { value: "test" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSearch).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("calls onReset when reset button clicked", () => {
|
||||
const onReset = vi.fn();
|
||||
const { container } = renderWithProviders(
|
||||
createElement(FilterToolbar, {
|
||||
search: {
|
||||
onReset,
|
||||
onSearch: () => {},
|
||||
placeholder: "搜索",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const resetBtn = container.querySelector<HTMLButtonElement>('button[title="重置"]');
|
||||
resetBtn?.click();
|
||||
expect(onReset).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,9 +74,9 @@ function renderModelTable(overrides?: Record<string, unknown>) {
|
||||
createElement(ModelTable, {
|
||||
data: { items: [ENABLED_MODEL, DISABLED_MODEL], page: 1, pageSize: 20, total: 2 },
|
||||
loading: false,
|
||||
onChange: () => undefined,
|
||||
onDelete: () => Promise.resolve(),
|
||||
onEdit: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
providers: [OPENAI_PROVIDER_OPTION, DEEPSEEK_PROVIDER_OPTION],
|
||||
@@ -90,9 +90,9 @@ function renderProviderTable(overrides?: Record<string, unknown>) {
|
||||
createElement(ProviderTable, {
|
||||
data: { items: [OPENAI_PROVIDER, DEEPSEEK_PROVIDER], page: 1, pageSize: 20, total: 2 },
|
||||
loading: false,
|
||||
onChange: () => undefined,
|
||||
onDelete: () => Promise.resolve(),
|
||||
onEdit: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...overrides,
|
||||
|
||||
25
tests/web/format.test.ts
Normal file
25
tests/web/format.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { formatDatetime } from "../../src/web/shared/utils/format";
|
||||
|
||||
describe("formatDatetime", () => {
|
||||
it("formats ISO date string to YYYY-MM-DD HH:mm:ss", () => {
|
||||
expect(formatDatetime("2024-06-15T14:30:45.123Z")).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("pads single-digit month, day, hour, minute, second", () => {
|
||||
const result = formatDatetime("2024-01-05T09:08:07.000Z");
|
||||
expect(result).toMatch(/2024-0[1-9]-0[1-9] 0[0-9]:0[0-9]:0[0-9]/);
|
||||
});
|
||||
|
||||
it("handles end-of-year date", () => {
|
||||
const result = formatDatetime("2024-12-31T23:59:59.000Z");
|
||||
expect(result).toContain("2024");
|
||||
expect(result).toContain("59:59");
|
||||
});
|
||||
|
||||
it("produces consistent output format", () => {
|
||||
const result = formatDatetime("2024-06-15T14:30:45.123Z");
|
||||
expect(result.length).toBe(19);
|
||||
});
|
||||
});
|
||||
@@ -310,8 +310,9 @@ describe("ModelListPage", () => {
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||||
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const input = screen.getByPlaceholderText("搜索模型名称或 ID");
|
||||
fireEvent.change(input, { target: { value: "gpt" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
|
||||
}, 15000);
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ function LocationProbe() {
|
||||
}
|
||||
|
||||
describe("ProjectsPage", () => {
|
||||
test("渲染项目管理入口并按状态请求项目列表", async () => {
|
||||
test("渲染项目管理入口并展示项目列表", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
@@ -123,27 +123,34 @@ describe("ProjectsPage", () => {
|
||||
expect(screen.getByText("活跃项目")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText("已归档")).not.toBeNull();
|
||||
expect(screen.getByText("归档项目")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull();
|
||||
expect(calls.some((call) => call.url.includes("status=active"))).toBe(true);
|
||||
expect(calls.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
|
||||
test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
fireEvent.change(searchInput1, { target: { value: "归档" } });
|
||||
fireEvent.keyDown(searchInput1, { key: "Enter" });
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
||||
fireEvent.click(screen.getByText("已归档"));
|
||||
|
||||
const statusLabels = screen.getAllByText("状态");
|
||||
const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
|
||||
if (selectLabel) fireEvent.mouseDown(selectLabel);
|
||||
await waitFor(() => {
|
||||
const archivedOptions = screen.getAllByText("已归档");
|
||||
const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
|
||||
if (dropdownOption) fireEvent.click(dropdownOption);
|
||||
});
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
|
||||
|
||||
expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
|
||||
expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
|
||||
});
|
||||
|
||||
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
|
||||
@@ -152,12 +159,19 @@ describe("ProjectsPage", () => {
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
fireEvent.change(searchInput2, { target: { value: "归档" } });
|
||||
fireEvent.keyDown(searchInput2, { key: "Enter" });
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索名称或描述"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
|
||||
const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
|
||||
if (clearButton) fireEvent.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
|
||||
expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
});
|
||||
|
||||
@@ -249,13 +263,12 @@ describe("ProjectsPage", () => {
|
||||
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
|
||||
loading: false,
|
||||
onArchive,
|
||||
onChange: () => undefined,
|
||||
onDelete,
|
||||
onEdit: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onRestore,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: "active",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -205,8 +205,9 @@ describe("ProviderListPage", () => {
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
||||
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
const input = screen.getByPlaceholderText("搜索供应商名称");
|
||||
fireEvent.change(input, { target: { value: "Open" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
|
||||
}, 15000);
|
||||
|
||||
|
||||
53
tests/web/use-confirm-action.test.ts
Normal file
53
tests/web/use-confirm-action.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { XProvider } from "@ant-design/x";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { App } from "antd";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { useConfirmAction } from "../../src/web/shared/hooks/useConfirmAction";
|
||||
|
||||
function createWrapper() {
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(XProvider, null, createElement(App, null, children));
|
||||
}
|
||||
|
||||
describe("useConfirmAction", () => {
|
||||
it("calls action successfully and resolves", async () => {
|
||||
let resolved = false;
|
||||
const action = () => {
|
||||
resolved = true;
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
|
||||
|
||||
await act(() => result.current.confirmAction(action, "成功"));
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("handles action error without throwing", async () => {
|
||||
const action = () => {
|
||||
throw new Error("失败");
|
||||
};
|
||||
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
|
||||
|
||||
await act(() => result.current.confirmAction(action, "成功"));
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("handles action error without throwing", async () => {
|
||||
const action = () => {
|
||||
throw new Error("失败");
|
||||
};
|
||||
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.confirmAction(action, "成功");
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
expect(threw).toBe(false);
|
||||
});
|
||||
});
|
||||
86
tests/web/use-page-search-params.test.ts
Normal file
86
tests/web/use-page-search-params.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
|
||||
import { usePageSearchParams } from "../../src/web/shared/hooks/usePageSearchParams";
|
||||
|
||||
function renderWithRouter(initialPath: string) {
|
||||
return renderHook(() => usePageSearchParams(), {
|
||||
wrapper: ({ children }) =>
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialPath] },
|
||||
createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithRouterAndDefaults(initialPath: string, defaults: Record<string, string>) {
|
||||
return renderHook(() => usePageSearchParams({ defaults }), {
|
||||
wrapper: ({ children }) =>
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialPath] },
|
||||
createElement(Routes, null, createElement(Route, { element: children, path: "*" })),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("usePageSearchParams", () => {
|
||||
it("returns empty params when URL has no search params", () => {
|
||||
const { result } = renderWithRouter("/test");
|
||||
expect(result.current.params).toEqual({});
|
||||
});
|
||||
|
||||
it("parses existing URL search params", () => {
|
||||
const { result } = renderWithRouter("/test?page=2&keyword=abc");
|
||||
expect(result.current.params).toEqual({ keyword: "abc", page: "2" });
|
||||
});
|
||||
|
||||
it("merges defaults for missing keys", () => {
|
||||
const { result } = renderWithRouterAndDefaults("/test", { page: "1", pageSize: "20" });
|
||||
expect(result.current.params).toEqual({ page: "1", pageSize: "20" });
|
||||
});
|
||||
|
||||
it("URL value takes precedence over default", () => {
|
||||
const { result } = renderWithRouterAndDefaults("/test?page=3", { page: "1" });
|
||||
expect(result.current.params).toEqual({ page: "3" });
|
||||
});
|
||||
|
||||
it("setParam updates a single param", () => {
|
||||
const { result } = renderWithRouter("/test");
|
||||
act(() => result.current.setParam("keyword", "hello"));
|
||||
expect(result.current.params["keyword"]).toBe("hello");
|
||||
});
|
||||
|
||||
it("setParam with undefined removes the param", () => {
|
||||
const { result } = renderWithRouter("/test?keyword=hello");
|
||||
act(() => result.current.setParam("keyword", undefined));
|
||||
expect(result.current.params["keyword"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setParam with empty string removes the param", () => {
|
||||
const { result } = renderWithRouter("/test?keyword=hello");
|
||||
act(() => result.current.setParam("keyword", ""));
|
||||
expect(result.current.params["keyword"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("setParams updates multiple params at once", () => {
|
||||
const { result } = renderWithRouter("/test");
|
||||
act(() => result.current.setParams({ keyword: "test", page: "2" }));
|
||||
expect(result.current.params).toEqual({ keyword: "test", page: "2" });
|
||||
});
|
||||
|
||||
it("setParams preserves existing params", () => {
|
||||
const { result } = renderWithRouter("/test?existing=yes");
|
||||
act(() => result.current.setParams({ page: "3" }));
|
||||
expect(result.current.params).toEqual({ existing: "yes", page: "3" });
|
||||
});
|
||||
|
||||
it("resetAll clears all params", () => {
|
||||
const { result } = renderWithRouter("/test?page=5&keyword=abc");
|
||||
act(() => result.current.resetAll());
|
||||
expect(result.current.params).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user