feat(inbox): 素材持久化 CRUD — 数据库表 + API + 前端接入
- 新增 materials 表(id/projectId/description/associatedDate/status/createdAt/updatedAt) - 新增 4 个后端 API 路由(list/create/get/delete)+ 13 个测试 - 新增 use-materials hooks(TanStack Query) - 收集箱页面重构为三层架构(InboxPage + MaterialSidebar + MaterialDetailPanel) - MaterialCard: Popconfirm 删除确认 + 粗粒度时间格式 - MaterialContent: 展示状态标签 + createdAt - 更新开发文档 backend.md / frontend.md
This commit is contained in:
283
tests/server/routes/materials.test.ts
Normal file
283
tests/server/routes/materials.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { Material, RuntimeMode } from "../../../src/shared/api";
|
||||
|
||||
import { archiveProject, createProject } from "../../../src/server/db/projects";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||
|
||||
const MODE: RuntimeMode = "test";
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
async function createMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleCreateMaterial: h } = await import("../../../src/server/routes/materials/create");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
function createTestProject(db: Database, name = "测试项目") {
|
||||
const result = createProject(db, { name }, LOG);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.project;
|
||||
}
|
||||
|
||||
async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleDeleteMaterial: h } = await import("../../../src/server/routes/materials/delete");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function getMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function listMaterialsViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleListMaterials: h } = await import("../../../src/server/routes/materials/list");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
|
||||
const handle = createMigratedMemoryTestDatabase("material-route-test");
|
||||
try {
|
||||
await callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe("素材 API 路由", () => {
|
||||
describe("POST /api/projects/:id/materials", () => {
|
||||
test("正常创建素材", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { material: Material };
|
||||
expect(body.material.description).toBe("测试素材");
|
||||
expect(body.material.associatedDate).toBe("2024-01-15");
|
||||
expect(body.material.projectId).toBe(project.id);
|
||||
expect(body.material.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
test("缺少 description 返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("缺少 associatedDate 返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ description: "测试素材" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("associatedDate 格式错误返回 400", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024/01/15", description: "测试素材" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("项目不存在返回 404", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/projects/nonexistent/materials", {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test("已归档项目返回 409", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
archiveProject(db, project.id, LOG);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试素材" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const res = await createMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects/:id/materials", () => {
|
||||
test("正常列表查询", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req1 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "素材1" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
await createMaterialViaHandler(req1, db);
|
||||
const req2 = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-16", description: "素材2" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
await createMaterialViaHandler(req2, db);
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
|
||||
const res = await listMaterialsViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: Material[]; total: number };
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.items.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test("空项目返回空列表", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials?page=1&pageSize=20`);
|
||||
const res = await listMaterialsViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: Material[]; total: number };
|
||||
expect(body.total).toBe(0);
|
||||
expect(body.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects/:id/materials/:mid", () => {
|
||||
test("正常获取详情", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "详情测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const createRes = await createMaterialViaHandler(createReq, db);
|
||||
const createBody = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
|
||||
const res = await getMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { material: Material };
|
||||
expect(body.material.description).toBe("详情测试");
|
||||
});
|
||||
});
|
||||
|
||||
test("归属错误返回 403", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const projectA = createTestProject(db, "项目A");
|
||||
const projectB = createProject(db, { name: "项目B" }, LOG);
|
||||
if ("error" in projectB) throw new Error(projectB.error);
|
||||
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const createRes = await createMaterialViaHandler(createReq, db);
|
||||
const createBody = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
|
||||
);
|
||||
const res = await getMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/projects/:id/materials/:mid", () => {
|
||||
test("正常删除", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const createRes = await createMaterialViaHandler(createReq, db);
|
||||
const createBody = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const res = await deleteMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
test("删除后再查返回 404", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createReq = new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "待删除" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const createRes = await createMaterialViaHandler(createReq, db);
|
||||
const createBody = (await createRes.json()) as { material: Material };
|
||||
|
||||
const delReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await deleteMaterialViaHandler(delReq, db);
|
||||
|
||||
const getReq = new Request(`http://localhost/api/projects/${project.id}/materials/${createBody.material.id}`);
|
||||
const res = await getMaterialViaHandler(getReq, db);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test("归属错误返回 403", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const projectA = createTestProject(db, "项目A");
|
||||
const projectB = createProject(db, { name: "项目B" }, LOG);
|
||||
if ("error" in projectB) throw new Error(projectB.error);
|
||||
const createReq = new Request(`http://localhost/api/projects/${projectA.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const createRes = await createMaterialViaHandler(createReq, db);
|
||||
const createBody = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${projectB.project.id}/materials/${createBody.material.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
const res = await deleteMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,21 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
import type { CreateMaterialRequest, Material } from "../../../../src/shared/api";
|
||||
|
||||
import { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_CREATED: Material = {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试描述",
|
||||
id: "new-id",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("AddMaterialModal", () => {
|
||||
test("打开时渲染表单字段", () => {
|
||||
renderWithProviders(
|
||||
@@ -49,7 +59,8 @@ describe("AddMaterialModal", () => {
|
||||
});
|
||||
|
||||
test("点击确定触发表单提交", async () => {
|
||||
const onAdd = vi.fn<(material: Material) => void>();
|
||||
const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise<Material>>();
|
||||
onAdd.mockResolvedValue(MOCK_CREATED);
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd,
|
||||
@@ -69,9 +80,29 @@ describe("AddMaterialModal", () => {
|
||||
|
||||
const callArgs = onAdd.mock.calls[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
const calledMaterial = callArgs![0];
|
||||
expect(calledMaterial.description).toBe("测试描述");
|
||||
expect(calledMaterial.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(calledMaterial.id).toBeTruthy();
|
||||
const calledBody = callArgs![0];
|
||||
expect(calledBody.description).toBe("测试描述");
|
||||
expect(calledBody.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
test("提交失败显示错误提示", async () => {
|
||||
const onAdd = vi.fn<(body: CreateMaterialRequest) => Promise<Material>>();
|
||||
onAdd.mockRejectedValue(new Error("网络错误"));
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd,
|
||||
onOpenChange: vi.fn(),
|
||||
open: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("请输入素材描述");
|
||||
fireEvent.change(textarea, { target: { value: "测试描述" } });
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,85 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Project } from "../../../../src/shared/api";
|
||||
|
||||
import { InboxPage } from "../../../../src/web/features/inbox";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { ProjectContext } from "../../../../src/web/shared/hooks/use-current-project";
|
||||
import { installFetchMock, jsonResponse, renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_PROJECT: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
id: "project-1",
|
||||
name: "测试项目",
|
||||
status: "active",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const EMPTY_LIST = { items: [], page: 1, pageSize: 20, total: 0 };
|
||||
|
||||
function makeMaterial(overrides: Partial<{ description: string; id: string }> = {}) {
|
||||
return {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: overrides.description ?? "测试素材",
|
||||
id: overrides.id ?? "mat-1",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function renderInboxPage() {
|
||||
return renderWithProviders(
|
||||
createElement(ProjectContext.Provider, {
|
||||
children: createElement(InboxPage),
|
||||
value: MOCK_PROJECT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("InboxPage", () => {
|
||||
test("初始状态显示空状态提示", () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
test("初始状态显示空列表和空详情", async () => {
|
||||
const calls = installFetchMock((call) => {
|
||||
if (call.url.includes("/materials")) return jsonResponse(EMPTY_LIST);
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderInboxPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
});
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
|
||||
const listCall = calls.find((c) => c.url.includes("/materials") && c.method === "GET");
|
||||
expect(listCall).toBeDefined();
|
||||
});
|
||||
|
||||
test("新增素材后列表追加且自动选中", async () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
const createdId = "mat-new";
|
||||
const created = makeMaterial({ description: "新增的素材", id: createdId });
|
||||
|
||||
installFetchMock((call) => {
|
||||
if (call.method === "POST" && call.url.includes("/materials")) {
|
||||
return jsonResponse({ material: created }, { status: 201 });
|
||||
}
|
||||
if (call.method === "GET" && call.url.includes("/materials/" + createdId)) {
|
||||
return jsonResponse({ material: created });
|
||||
}
|
||||
if (call.method === "GET" && call.url.includes("/materials")) {
|
||||
return jsonResponse({ ...EMPTY_LIST, items: [created], total: 1 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
renderInboxPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /新增素材/ })).not.toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
|
||||
|
||||
@@ -27,27 +94,28 @@ describe("InboxPage", () => {
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("新增的素材")).not.toBeNull();
|
||||
const cards = screen.getAllByText("新增的素材");
|
||||
expect(cards.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText("素材详情")).not.toBeNull();
|
||||
expect(screen.queryByText("暂无素材")).toBeNull();
|
||||
expect(screen.queryByText("请在左侧选择素材")).toBeNull();
|
||||
});
|
||||
|
||||
test("删除素材后列表更新", async () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
let deleted = false;
|
||||
const material = makeMaterial({ description: "待删除的素材", id: "mat-del" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
|
||||
installFetchMock((call) => {
|
||||
if (call.method === "DELETE" && call.url.includes("/materials/" + material.id)) {
|
||||
deleted = true;
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (call.method === "GET" && call.url.includes("/materials")) {
|
||||
if (deleted) return jsonResponse(EMPTY_LIST);
|
||||
return jsonResponse({ ...EMPTY_LIST, items: [material], total: 1 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
|
||||
const textarea = screen.getByPlaceholderText("请输入素材描述");
|
||||
fireEvent.change(textarea, { target: { value: "待删除的素材" } });
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
renderInboxPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("待删除的素材")).not.toBeNull();
|
||||
@@ -55,9 +123,14 @@ describe("InboxPage", () => {
|
||||
|
||||
fireEvent.click(screen.getByLabelText("删除"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("确认删除该素材?")).not.toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("删 除"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
import type { Material } from "../../../../src/shared/api";
|
||||
|
||||
import { MaterialCard } from "../../../../src/web/features/inbox/components/MaterialCard";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_MATERIAL: Material = {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试素材描述",
|
||||
id: "test-id",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("MaterialCard", () => {
|
||||
test("渲染素材描述和日期信息", () => {
|
||||
test("渲染素材描述和创建时间", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
@@ -25,7 +28,7 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||
expect(screen.getByText(/2026-06-03/)).not.toBeNull();
|
||||
expect(screen.getByText("今天")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击卡片触发 onSelect", () => {
|
||||
@@ -43,7 +46,7 @@ describe("MaterialCard", () => {
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("点击删除按钮触发 onDelete 且不触发 onSelect", () => {
|
||||
test("点击删除按钮弹出确认框,确认后触发 onDelete", async () => {
|
||||
const onDelete = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
@@ -55,11 +58,20 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("删除"));
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("确认删除该素材?")).not.toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("删 除"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("选中状态添加 app-inbox-card-selected 类", () => {
|
||||
test("选中状态不再使用 app-inbox-card-selected 类", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
@@ -69,6 +81,6 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected");
|
||||
expect(card).not.toBeNull();
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,28 +2,39 @@ import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
import type { Material } from "../../../../src/shared/api";
|
||||
|
||||
import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_MATERIAL: Material = {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "详细描述内容",
|
||||
id: "test-id",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("MaterialContent", () => {
|
||||
test("未选中时显示空状态提示", () => {
|
||||
renderWithProviders(createElement(MaterialContent, { material: null }));
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("选中时展示素材详情", () => {
|
||||
test("展示素材详情和状态", () => {
|
||||
renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL }));
|
||||
expect(screen.getByText("素材详情")).not.toBeNull();
|
||||
expect(screen.getByText("详细描述内容")).not.toBeNull();
|
||||
expect(screen.getByText("2026-06-03")).not.toBeNull();
|
||||
expect(screen.getByText("待审核")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("展示已通过状态", () => {
|
||||
const approved: Material = { ...MOCK_MATERIAL, status: "approved" };
|
||||
renderWithProviders(createElement(MaterialContent, { material: approved }));
|
||||
expect(screen.getByText("已通过")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("展示已放弃状态", () => {
|
||||
const discarded: Material = { ...MOCK_MATERIAL, status: "discarded" };
|
||||
renderWithProviders(createElement(MaterialContent, { material: discarded }));
|
||||
expect(screen.getByText("已放弃")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
import type { Material } from "../../../../src/shared/api";
|
||||
|
||||
import { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
@@ -10,15 +10,21 @@ import { renderWithProviders } from "../../test-utils";
|
||||
const MOCK_MATERIALS: Material[] = [
|
||||
{
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "素材一",
|
||||
id: "id-1",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
associatedDate: "2026-06-02",
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: "2026-06-02T00:00:00.000Z",
|
||||
description: "素材二",
|
||||
id: "id-2",
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -26,6 +32,7 @@ describe("MaterialList", () => {
|
||||
test("列表为空时显示暂无素材", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
loading: false,
|
||||
materials: [],
|
||||
onAddClick: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
@@ -39,6 +46,7 @@ describe("MaterialList", () => {
|
||||
test("渲染素材卡片列表", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
loading: false,
|
||||
materials: MOCK_MATERIALS,
|
||||
onAddClick: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
@@ -54,6 +62,7 @@ describe("MaterialList", () => {
|
||||
const onAddClick = vi.fn();
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
loading: false,
|
||||
materials: [],
|
||||
onAddClick,
|
||||
onDelete: vi.fn(),
|
||||
@@ -64,4 +73,18 @@ describe("MaterialList", () => {
|
||||
screen.getByText("新增素材").click();
|
||||
expect(onAddClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("加载中显示 Spin", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
loading: true,
|
||||
materials: [],
|
||||
onAddClick: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selectedId: null,
|
||||
}),
|
||||
);
|
||||
expect(document.querySelector(".ant-spin")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user