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:
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user