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 { 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: Project = { archivedAt: null, createdAt: "2024-01-01T00:00:00.000Z", description: "活跃描述", id: "p1", name: "活跃项目", status: "active", updatedAt: "2024-01-01T00:00:00.000Z", }; const ARCHIVED_PROJECT: Project = { archivedAt: "2024-01-02T00:00:00.000Z", createdAt: "2024-01-01T00:00:00.000Z", description: "归档描述", id: "p2", name: "归档项目", status: "archived", updatedAt: "2024-01-02T00:00:00.000Z", }; async function clickLatestConfirmButton() { const confirmTexts = await screen.findAllByText(/确\s*定|OK|确认/); const lastText = confirmTexts[confirmTexts.length - 1]!; const button = lastText.closest("button") ?? lastText.closest("[role='button']") ?? lastText; fireEvent.click(button); } 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 }); } 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("渲染项目管理入口并按状态请求项目列表", async () => { const calls = createProjectFetchMock(); renderWithProviders(createElement(App), { initialRoute: "/projects" }); await waitFor(() => { 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("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => { const calls = createProjectFetchMock(); renderWithProviders(createElement(App), { initialRoute: "/projects" }); await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull()); fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } }); fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ })); await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true)); fireEvent.click(screen.getByText("已归档")); await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull()); expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true); expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true); }); test("清空搜索条件复位请求参数并重新展示全部项目", async () => { const calls = createProjectFetchMock(); renderWithProviders(createElement(App), { initialRoute: "/projects" }); await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull()); fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } }); fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ })); 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: "新增描述" } }); await 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, }), ); await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull()); fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } }); await clickLatestConfirmButton(); 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()); await clickLatestConfirmButton(); expect(onCreate).not.toHaveBeenCalled(); fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } }); await 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()); await clickLatestConfirmButton(); await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1")); fireEvent.click(screen.getByRole("button", { name: /恢复/ })); await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull()); await clickLatestConfirmButton(); await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2")); fireEvent.click(screen.getByRole("button", { name: /删除/ })); await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull()); await clickLatestConfirmButton(); await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2")); }, 15000); });