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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
62
tests/web/components/ErrorBoundary.test.tsx
Normal file
62
tests/web/components/ErrorBoundary.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
88
tests/web/hooks/use-projects.test.ts
Normal file
88
tests/web/hooks/use-projects.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
tests/web/hooks/use-sidebar-collapsed.test.ts
Normal file
54
tests/web/hooks/use-sidebar-collapsed.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
77
tests/web/hooks/use-theme-preference.test.ts
Normal file
77
tests/web/hooks/use-theme-preference.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user