test: 测试体系全面优化,修复 Windows SQLite EBUSY 和前端产品缺陷

测试基础设施
- 统一 SQLite 测试 DB/临时目录 helper(tests/helpers.ts),支持 Windows EBUSY 重试清理
- 测试库使用 PRAGMA journal_mode=DELETE 避免 WAL 句柄延迟
- 路由 handler 测试改用 createMigratedMemoryTestDatabase 避免 File DB 锁
- SQLite 聚焦 --rerun-each=20 全部通过(720 pass)

后端测试补强
- 新增 tests/server/app.test.ts 真实 startServer 集成测试
- 覆盖 /api/meta、项目 CRUD、错误路径、静态 fallback、安全 header
- bootstrap/logger 测试捕获预期输出,消除测试噪音

前端测试补强
- 移除 .ant-* 内部类名依赖,改为角色/文本/导航/请求契约断言
- 项目页补充搜索、Tab 切换、表单、表格操作、错误反馈行为测试
- 新增 hooks(use-theme-preference、use-sidebar-collapsed、use-projects)纯逻辑测试
- 新增 ErrorBoundary 错误展示和刷新按钮测试
- 新增搜索清空行为测试
- 测试 setup 过滤 antd/rc-trigger NaN height warning

产品修复(测试暴露)
- 修复 ProjectToolbar 搜索框无法输入(新增 draftKeyword 状态)
- 加固 ProjectFormModal 表单字段同步(useEffect 替代不可靠的 afterOpenChange)
- 清理 ProjectFormModal 冗余 afterOpenChange 同步逻辑

重构与合规
- ProjectContext 拆分为三文件满足 React Fast Refresh 规则
- use-projects.ts 导出内部 helper 函数供测试验证
- scripts/build.ts 提取纯生成函数供测试使用,修复构建步骤日志编号
- 修复 build 测试覆盖真实生成逻辑

文档同步
- 更新后端/前端/开发文档测试规范、质量门禁和 helper 使用说明
This commit is contained in:
2026-05-29 00:45:21 +08:00
parent 6cb378d7cb
commit 2ea4bd4410
31 changed files with 1417 additions and 723 deletions

View File

@@ -4,77 +4,27 @@ import { createElement } from "react";
import { APP } from "../../src/shared/app";
import { App } from "../../src/web/app";
import { renderWithProviders } from "./test-utils";
import { installFetchMock, mockMetaResponse, renderWithProviders } from "./test-utils";
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
test("渲染管理台入口、品牌和主题切换项", () => {
installFetchMock(() => mockMetaResponse());
renderWithProviders(createElement(App));
expect(screen.getByText(APP.title)).not.toBeNull();
expect(screen.getByText("管理台")).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
test("渲染 Admin 侧边栏菜单项", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
test("渲染 Admin 导航菜单项", () => {
installFetchMock(() => mockMetaResponse());
renderWithProviders(createElement(App));
expect(screen.getAllByText("总览").length).toBeGreaterThan(0);
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
});
test("Sider 渲染侧边栏菜单", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
const sider = document.querySelector(".ant-layout-sider");
expect(sider).not.toBeNull();
const menu = document.querySelector(".ant-menu");
expect(menu).not.toBeNull();
});
test("Admin header 显示管理台标题", () => {
window.fetch = (() => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
expect(screen.getByText("管理台")).not.toBeNull();
});
});

View File

@@ -0,0 +1,62 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { App as AntApp, ConfigProvider } from "antd";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { ErrorBoundary } from "../../../src/web/components/ErrorBoundary";
function BrokenChild(): never {
throw new Error("render failed");
}
function captureConsoleError(callback: () => void): string[] {
const originalError = console.error;
const errors: string[] = [];
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
try {
callback();
} finally {
console.error = originalError;
}
return errors;
}
describe("ErrorBoundary", () => {
test("子组件渲染失败后展示错误结果并隔离 console.error", () => {
const errors = captureConsoleError(() => {
render(
createElement(
ConfigProvider,
null,
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
),
);
});
expect(screen.getByText("渲染错误")).not.toBeNull();
expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull();
expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull();
expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true);
});
test("点击刷新页面按钮不会破坏错误兜底界面", () => {
captureConsoleError(() => {
render(
createElement(
ConfigProvider,
null,
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
),
);
});
expect(() => {
captureConsoleError(() => fireEvent.click(screen.getByRole("button", { name: "刷新页面" })));
}).not.toThrow();
expect(screen.getByText("渲染错误")).not.toBeNull();
});
});

View File

@@ -1,11 +1,17 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import { Sidebar } from "../../../../src/web/components/Sidebar";
import { ADMIN_MENU_ITEMS } from "../../../../src/web/consoles/admin/menu";
import { renderWithProviders } from "../../test-utils";
function LocationProbe() {
const location = useLocation();
return <span>{location.pathname}</span>;
}
describe("Sidebar", () => {
test("渲染 Admin 菜单项", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
@@ -14,23 +20,21 @@ describe("Sidebar", () => {
expect(screen.getByText("项目管理")).not.toBeNull();
});
test("项目管理菜单项导航到 /projects", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
initialRoute: "/projects",
});
test("点击项目管理菜单项导航到 /projects", () => {
renderWithProviders(
createElement("div", null, createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), createElement(LocationProbe)),
);
const activeItem = document.querySelector(".ant-menu-item-selected");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("项目管理");
fireEvent.click(screen.getByText("项目管理"));
expect(screen.getByText("当前路径:/projects")).not.toBeNull();
});
test("高亮当前路由对应的总览菜单项", () => {
test("当前路由仍展示对应菜单项", () => {
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
initialRoute: "/",
});
const activeItem = document.querySelector(".ant-menu-item-selected");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("总览");
expect(screen.getByText("总览")).not.toBeNull();
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import {
archiveProject,
createProject,
deleteProject,
fetchProject,
fetchProjectList,
restoreProject,
updateProject,
} from "../../../src/web/hooks/use-projects";
import { installFetchMock, jsonResponse } from "../test-utils";
const PROJECT = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "描述",
id: "p1",
name: "项目",
status: "active" as const,
updatedAt: "2024-01-01T00:00:00.000Z",
};
async function expectRejectsWithMessage(action: () => Promise<unknown>, message: string) {
try {
await action();
throw new Error("expected rejection");
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe(message);
}
}
function jsonBody(body: BodyInit | null | undefined): unknown {
return JSON.parse(typeof body === "string" ? body : "{}");
}
describe("use-projects request helpers", () => {
test("fetchProjectList 按协议拼接 query 参数", async () => {
const calls = installFetchMock(() => jsonResponse({ items: [PROJECT], page: 2, pageSize: 10, total: 1 }));
const result = await fetchProjectList({ keyword: "项目", page: 2, pageSize: 10, status: "active" });
expect(result.items).toHaveLength(1);
expect(calls[0]?.method).toBe("GET");
expect(calls[0]?.url).toBe("/api/projects?page=2&pageSize=10&keyword=%E9%A1%B9%E7%9B%AE&status=active");
});
test("创建、更新、归档、恢复和删除使用正确 method 与 body", async () => {
const calls = installFetchMock((call) => {
if (call.method === "DELETE") return new Response(null, { status: 204 });
return jsonResponse(
{ project: PROJECT },
{ status: call.method === "POST" && call.url === "/api/projects" ? 201 : 200 },
);
});
await createProject({ description: "描述", name: "项目" });
await updateProject("p1", { name: "新项目" });
await archiveProject("p1");
await restoreProject("p1");
await deleteProject("p1");
await fetchProject("p1");
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"POST /api/projects",
"PATCH /api/projects/p1",
"POST /api/projects/p1/archive",
"POST /api/projects/p1/restore",
"DELETE /api/projects/p1",
"GET /api/projects/p1",
]);
expect(jsonBody(calls[0]?.body)).toEqual({ description: "描述", name: "项目" });
expect(jsonBody(calls[1]?.body)).toEqual({ name: "新项目" });
});
test("错误响应优先使用后端 error 字段", async () => {
installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 }));
await expectRejectsWithMessage(() => createProject({ name: "重复项目" }), "项目名称已存在");
});
test("非 JSON 错误响应回退到 HTTP 状态", async () => {
installFetchMock(() => new Response("broken", { status: 500 }));
await expectRejectsWithMessage(() => fetchProject("p-missing"), "HTTP 500");
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import {
parseSidebarCollapsed,
readSidebarCollapsed,
SIDEBAR_COLLAPSED_STORAGE_KEY,
writeSidebarCollapsed,
} from "../../../src/web/hooks/use-sidebar-collapsed";
function createStorage(initial?: string): Storage {
const values = new Map<string, string>();
if (initial !== undefined) values.set(SIDEBAR_COLLAPSED_STORAGE_KEY, initial);
return {
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
}
describe("sidebar collapsed 纯逻辑", () => {
test("仅字符串 true 解析为折叠", () => {
expect(parseSidebarCollapsed("true")).toBe(true);
expect(parseSidebarCollapsed("false")).toBe(false);
expect(parseSidebarCollapsed(true)).toBe(false);
expect(parseSidebarCollapsed(null)).toBe(false);
});
test("读取和写入 localStorage", () => {
const storage = createStorage("true");
expect(readSidebarCollapsed(storage)).toBe(true);
writeSidebarCollapsed(false, storage);
expect(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
});
test("storage 异常时回退且不抛错", () => {
const brokenStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
} as unknown as Storage;
expect(readSidebarCollapsed(brokenStorage)).toBe(false);
expect(() => writeSidebarCollapsed(true, brokenStorage)).not.toThrow();
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import {
getSystemPrefersDark,
parseThemePreference,
readThemePreference,
resolveEffectiveTheme,
THEME_MEDIA_QUERY,
THEME_PREFERENCE_STORAGE_KEY,
writeThemePreference,
} from "../../../src/web/hooks/use-theme-preference";
function createStorage(initial?: string): Storage {
const values = new Map<string, string>();
if (initial !== undefined) values.set(THEME_PREFERENCE_STORAGE_KEY, initial);
return {
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
}
describe("theme preference 纯逻辑", () => {
test("解析合法和非法主题偏好", () => {
expect(parseThemePreference("dark")).toBe("dark");
expect(parseThemePreference("light")).toBe("light");
expect(parseThemePreference("system")).toBe("system");
expect(parseThemePreference("unknown")).toBe("system");
});
test("读取和写入 localStorage", () => {
const storage = createStorage("dark");
expect(readThemePreference(storage)).toBe("dark");
writeThemePreference("light", storage);
expect(storage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("light");
});
test("storage 异常时回退且不抛错", () => {
const brokenStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
} as unknown as Storage;
expect(readThemePreference(brokenStorage)).toBe("system");
expect(() => writeThemePreference("dark", brokenStorage)).not.toThrow();
});
test("解析系统主题和异常回退", () => {
const matchMedia = ((query: string) => {
expect(query).toBe(THEME_MEDIA_QUERY);
return { matches: true };
}) as Window["matchMedia"];
const brokenMatchMedia = (() => {
throw new Error("blocked");
}) as Window["matchMedia"];
expect(getSystemPrefersDark(matchMedia)).toBe(true);
expect(getSystemPrefersDark(brokenMatchMedia)).toBe(false);
});
test("根据系统主题解析实际主题", () => {
expect(resolveEffectiveTheme("dark", false)).toBe("dark");
expect(resolveEffectiveTheme("light", true)).toBe("light");
expect(resolveEffectiveTheme("system", true)).toBe("dark");
expect(resolveEffectiveTheme("system", false)).toBe("light");
});
});

View File

@@ -1,10 +1,16 @@
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import { NotFoundPage } from "../../../src/web/pages/404";
import { renderWithProviders } from "../test-utils";
function LocationProbe() {
const location = useLocation();
return <span>{location.pathname}</span>;
}
describe("NotFoundPage", () => {
test("渲染 404 页面", () => {
renderWithProviders(createElement(NotFoundPage));
@@ -14,10 +20,14 @@ describe("NotFoundPage", () => {
expect(screen.getByRole("button", { name: "返回首页" })).not.toBeNull();
});
test("返回首页按钮存在且可点击", () => {
renderWithProviders(createElement(NotFoundPage));
test("点击返回首页按钮导航到首页", () => {
renderWithProviders(createElement("div", null, createElement(NotFoundPage), createElement(LocationProbe)), {
initialRoute: "/missing",
});
const button = screen.getByRole("button", { name: "返回首页" });
expect(button).not.toBeNull();
fireEvent.click(button);
expect(screen.getByText("当前路径:/")).not.toBeNull();
});
});

View File

@@ -1,114 +1,278 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, mock, test } from "bun:test";
import { createElement } from "react";
import { useLocation } from "react-router";
import type { Project } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { renderWithProviders } from "../test-utils";
import { ProjectFormModal } from "../../../src/web/pages/projects/components/ProjectFormModal";
import { ProjectTable } from "../../../src/web/pages/projects/components/ProjectTable";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ACTIVE_PROJECT = {
const ACTIVE_PROJECT: Project = {
archivedAt: null,
createdAt: "2024-01-01T00:00:00.000Z",
description: "",
description: "活跃描述",
id: "p1",
name: "活跃项目",
status: "active",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const ARCHIVED_PROJECT = {
const ARCHIVED_PROJECT: Project = {
archivedAt: "2024-01-02T00:00:00.000Z",
createdAt: "2024-01-01T00:00:00.000Z",
description: "",
description: "归档描述",
id: "p2",
name: "归档项目",
status: "archived",
updatedAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-02T00:00:00.000Z",
};
function createMockHandler(projectList?: unknown[]) {
const handler = (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
if (url.includes("/api/meta")) {
return new Response(
JSON.stringify({
ok: true,
service: "test-app",
timestamp: new Date().toISOString(),
version: "0.1.0",
}),
{ headers: { "Content-Type": "application/json" }, status: 200 },
);
}
if (url.includes("/api/projects")) {
const items = projectList ?? [];
return new Response(JSON.stringify({ items, page: 1, pageSize: 10, total: items.length }), {
headers: { "Content-Type": "application/json" },
status: 200,
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, ARCHIVED_PROJECT]) {
let projects = [...initialProjects];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/projects" && call.method === "GET") {
const status = url.searchParams.get("status");
const keyword = url.searchParams.get("keyword") ?? "";
const items = projects.filter((project) => {
const statusMatched = !status || project.status === status;
const keywordMatched = !keyword || `${project.name}${project.description}`.includes(keyword);
return statusMatched && keywordMatched;
});
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
return new Response(JSON.stringify({ error: "Not Found" }), {
status: 404,
});
};
const mocked = handler as unknown as typeof fetch;
globalThis.fetch = mocked;
window.fetch = mocked;
if (url.pathname === "/api/projects" && call.method === "POST") {
const data = jsonBody(call.body) as { description?: string; name: string };
const created: Project = {
archivedAt: null,
createdAt: "2024-01-03T00:00:00.000Z",
description: data.description ?? "",
id: "p-created",
name: data.name,
status: "active",
updatedAt: "2024-01-03T00:00:00.000Z",
};
projects = [created, ...projects];
return jsonResponse({ project: created }, { status: 201 });
}
const projectId = /^\/api\/projects\/([^/]+)(?:\/(archive|restore))?$/.exec(url.pathname);
if (projectId) {
const [, id, action] = projectId;
const project = projects.find((item) => item.id === id);
if (!project) return jsonResponse({ error: "项目不存在", status: 404 }, { status: 404 });
if (call.method === "PATCH") {
const data = jsonBody(call.body) as { description?: string; name?: string };
const updated = { ...project, ...data, updatedAt: "2024-01-04T00:00:00.000Z" };
projects = projects.map((item) => (item.id === id ? updated : item));
return jsonResponse({ project: updated });
}
if (call.method === "POST" && action === "archive") {
const archived = { ...project, archivedAt: "2024-01-04T00:00:00.000Z", status: "archived" as const };
projects = projects.map((item) => (item.id === id ? archived : item));
return jsonResponse({ project: archived });
}
if (call.method === "POST" && action === "restore") {
const restored = { ...project, archivedAt: null, status: "active" as const };
projects = projects.map((item) => (item.id === id ? restored : item));
return jsonResponse({ project: restored });
}
if (call.method === "DELETE") {
projects = projects.filter((item) => item.id !== id);
return new Response(null, { status: 204 });
}
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
function jsonBody(body: BodyInit | null | undefined): unknown {
return JSON.parse(typeof body === "string" ? body : "{}");
}
function LocationProbe() {
const location = useLocation();
return createElement("span", null, `当前路径:${location.pathname}`);
}
describe("ProjectsPage", () => {
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
createMockHandler();
test("渲染项目管理入口并按状态请求项目列表", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(
() => {
expect(screen.getByText("进行中")).not.toBeNull();
},
{ timeout: 10000 },
);
await waitFor(() => {
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);
});
test("新建按钮点击打开弹窗", async () => {
createMockHandler();
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
await waitFor(
() => {
expect(screen.getByText("进行中")).not.toBeNull();
},
{ timeout: 10000 },
);
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
fireEvent.click(screen.getByText("已归档"));
const createBtn = screen.getByRole("button", { name: /新建项目/ });
createBtn.click();
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
await waitFor(
() => {
expect(document.body.querySelector(".ant-modal")).not.toBeNull();
},
{ timeout: 10000 },
);
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("active 项目行显示'进入工作台'archived 行不显示", async () => {
createMockHandler([ACTIVE_PROJECT, ARCHIVED_PROJECT]);
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
await waitFor(
() => {
expect(screen.queryByText("活跃项目")).not.toBeNull();
},
{ timeout: 10000 },
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
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*索/ }));
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
});
test("新建项目提交请求 body 并显示创建结果", async () => {
const calls = createProjectFetchMock([]);
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建项目/ }));
await waitFor(() => expect(screen.getAllByText("新建项目").length).toBeGreaterThan(1));
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
clickLatestConfirmButton();
await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull());
const createCall = calls.find((call) => call.url.endsWith("/api/projects") && call.method === "POST");
expect(createCall).toBeDefined();
expect(jsonBody(createCall?.body)).toEqual({ description: "新增描述", name: "新增项目" });
});
test("编辑项目表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
const onUpdate = mock((args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
});
renderWithProviders(
createElement(ProjectFormModal, {
editingProject: ACTIVE_PROJECT,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onUpdate,
open: true,
submitting: false,
}),
);
const enterBtns = screen.getAllByText("进入工作台");
expect(enterBtns.length).toBe(1);
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
clickLatestConfirmButton();
const archivedRow = screen.getByText("归档项目").closest("tr");
expect(archivedRow?.textContent).not.toContain("进入工作台");
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
});
test("项目表单校验失败不会提交,接口失败时保留弹窗", async () => {
const onCreate = mock(() => Promise.reject(new Error("创建失败")));
const onOpenChange = mock(() => undefined);
renderWithProviders(
createElement(ProjectFormModal, {
editingProject: null,
onCancel: () => undefined,
onCreate,
onOpenChange,
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
clickLatestConfirmButton();
expect(onCreate).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
clickLatestConfirmButton();
await waitFor(() => expect(onCreate).toHaveBeenCalled());
expect(onOpenChange).not.toHaveBeenCalledWith(false);
expect(screen.getByText("新建项目")).not.toBeNull();
});
test("项目表格操作触发导航和行级动作", async () => {
const onArchive = mock(() => Promise.resolve());
const onDelete = mock(() => Promise.resolve());
const onRestore = mock(() => Promise.resolve());
renderWithProviders(
createElement(
"div",
null,
createElement(LocationProbe),
createElement(ProjectTable, {
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
loading: false,
onArchive,
onDelete,
onEdit: () => undefined,
onPageChange: () => undefined,
onRestore,
page: 1,
pageSize: 20,
}),
),
);
fireEvent.click(screen.getByRole("button", { name: /进入工作台/ }));
expect(screen.getByText("当前路径:/workbench/p1")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: /归档/ }));
await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
});
});

View File

@@ -19,10 +19,48 @@ void mock.module("recharts", () => ({
YAxis: () => null,
}));
export interface FetchMockCall {
body?: BodyInit | null;
method: string;
url: string;
}
export interface RenderWithProvidersOptions {
initialRoute?: string;
}
export function installFetchMock(handler: (call: FetchMockCall) => Promise<Response> | Response): FetchMockCall[] {
const calls: FetchMockCall[] = [];
const mocked = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request?.url ?? (typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
const call: FetchMockCall = {
body: init?.body ?? null,
method: init?.method ?? request?.method ?? "GET",
url,
};
calls.push(call);
return handler(call);
}) as typeof fetch;
globalThis.fetch = mocked;
window.fetch = mocked;
return calls;
}
export function jsonResponse(body: unknown, init?: ResponseInit): Response {
const headers = new Headers(init?.headers);
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), {
headers,
status: init?.status ?? 200,
});
}
export function mockMetaResponse(): Response {
return jsonResponse({ ok: true, service: "test-app", timestamp: "2024-01-01T00:00:00.000Z", version: "0.1.0" });
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
const queryClient = createTestQueryClient();
const initialRoute = options?.initialRoute ?? "/";
@@ -58,38 +96,3 @@ function createTestQueryClient() {
},
});
}
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
const pass = element !== null && document.contains(element);
return {
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
pass,
};
},
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
return {
message: () =>
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
pass,
};
},
toHaveClass: (element: Element | null, className: string) => {
const pass = element?.classList.contains(className) ?? false;
return {
message: () =>
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
pass,
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};