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

@@ -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"));
});
});