Files
Alfred/tests/web/routes/projects.test.tsx

292 lines
12 KiB
TypeScript

import { describe, expect, mock, test } from "bun:test";
import { fireEvent, screen, waitFor } from "@testing-library/react";
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/features/projects/components/ProjectFormModal";
import { ProjectTable } from "../../../src/web/features/projects/components/ProjectTable";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ACTIVE_PROJECT: Project = {
createdAt: "2024-01-01T00:00:00.000Z",
description: "活跃描述",
id: "p1",
name: "活跃项目",
status: "active",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const ARCHIVED_PROJECT: Project = {
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 = {
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, 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, 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.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
});
test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await screen.findByText("活跃项目");
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.change(searchInput1, { target: { value: "归档" } });
fireEvent.keyDown(searchInput1, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
const statusLabels = screen.getAllByText("状态");
const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
if (selectLabel) fireEvent.mouseDown(selectLabel);
await waitFor(() => {
const archivedOptions = screen.getAllByText("已归档");
const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
if (dropdownOption) fireEvent.click(dropdownOption);
});
await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
await screen.findByText("归档项目");
}, 30000);
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
const calls = createProjectFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await screen.findByText("活跃项目");
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
fireEvent.change(searchInput2, { target: { value: "归档" } });
fireEvent.keyDown(searchInput2, { key: "Enter" });
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
if (clearButton) fireEvent.click(clearButton);
await waitFor(() => {
const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
});
await screen.findByText("活跃项目");
});
test("新建项目提交请求 body 并显示创建结果", async () => {
const calls = createProjectFetchMock([]);
renderWithProviders(createElement(App), { initialRoute: "/projects" });
await screen.findByRole("button", { name: /新建项目/ });
fireEvent.click(screen.getByRole("button", { name: /新建项目/ }));
await waitFor(() => expect(screen.getAllByText("新建项目").length).toBeGreaterThan(1));
await screen.findByPlaceholderText("请输入项目名称");
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
await clickLatestConfirmButton();
await screen.findByText("新增项目");
const createCall = calls.find((call) => call.url.endsWith("/api/projects") && call.method === "POST");
expect(createCall).toBeDefined();
expect(jsonBody(createCall?.body)).toEqual({ description: "新增描述", name: "新增项目" });
}, 30000);
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 screen.findByPlaceholderText("请输入项目名称");
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
await clickLatestConfirmButton();
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
}, 30000);
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 screen.findByPlaceholderText("请输入项目名称");
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();
}, 30000);
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,
onChange: () => undefined,
onDelete,
onEdit: () => 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 screen.findByText("确认归档此项目?");
await clickLatestConfirmButton();
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
await screen.findByText("确认恢复此项目?");
await clickLatestConfirmButton();
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
await screen.findByText("确认永久删除此项目?");
await clickLatestConfirmButton();
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
}, 30000);
});