// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DRAFT_FIRST_CHAPTER, DRAFT_PLACEHOLDER_TITLE, useDeferredDraft, } from "./useDeferredDraft"; // 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。 const post = vi.fn(); const put = vi.fn(); const toast = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a), PUT: (...a: unknown[]) => put(...a), }, })); vi.mock("@/components/Toast", () => ({ useToast: () => toast })); describe("useDeferredDraft", () => { beforeEach(() => { post.mockReset(); put.mockReset(); toast.mockReset(); }); afterEach(() => vi.clearAllMocks()); it("初始为 idle", () => { const { result } = renderHook(() => useDeferredDraft()); expect(result.current.status).toBe("idle"); }); it("首次落库:懒建占位项目 + 用已敲入正文种首章草稿,返回新项目 id", async () => { post.mockResolvedValue({ data: { id: "p1" }, error: null }); put.mockResolvedValue({ data: {}, error: null }); const { result } = renderHook(() => useDeferredDraft()); let id: string | null = null; await act(async () => { id = await result.current.ensureCreated("第一段正文"); }); expect(id).toBe("p1"); expect(result.current.status).toBe("created"); expect(post).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledWith("/projects", { body: { title: DRAFT_PLACEHOLDER_TITLE }, }); expect(put).toHaveBeenCalledWith( "/projects/{project_id}/chapters/{chapter_no}/draft", { params: { path: { project_id: "p1", chapter_no: DRAFT_FIRST_CHAPTER } }, body: { text: "第一段正文" }, }, ); expect(toast).not.toHaveBeenCalled(); }); it("幂等:已建后再次调用复用同一 id,绝不重复 POST", async () => { post.mockResolvedValue({ data: { id: "p1" }, error: null }); put.mockResolvedValue({ data: {}, error: null }); const { result } = renderHook(() => useDeferredDraft()); await act(async () => { await result.current.ensureCreated("正文一"); }); let second: string | null = null; await act(async () => { second = await result.current.ensureCreated("正文二"); }); expect(second).toBe("p1"); expect(post).toHaveBeenCalledTimes(1); }); it("正文为空白:仍懒建项目但跳过草稿落库", async () => { post.mockResolvedValue({ data: { id: "p1" }, error: null }); const { result } = renderHook(() => useDeferredDraft()); await act(async () => { await result.current.ensureCreated(" "); }); expect(post).toHaveBeenCalledTimes(1); expect(put).not.toHaveBeenCalled(); }); it("创建失败:status=error、弹错误 toast、返回 null、不落草稿", async () => { post.mockResolvedValue({ data: null, error: { detail: "配额不足" } }); const { result } = renderHook(() => useDeferredDraft()); let id: string | null = "sentinel"; await act(async () => { id = await result.current.ensureCreated("正文"); }); expect(id).toBeNull(); expect(result.current.status).toBe("error"); expect(put).not.toHaveBeenCalled(); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); }); it("草稿落库失败为非致命:项目已建仍返回 id,提示但不阻断", async () => { post.mockResolvedValue({ data: { id: "p1" }, error: null }); put.mockResolvedValue({ data: null, error: { detail: "写库失败" } }); const { result } = renderHook(() => useDeferredDraft()); let id: string | null = null; await act(async () => { id = await result.current.ensureCreated("正文"); }); expect(id).toBe("p1"); expect(result.current.status).toBe("created"); expect(toast).toHaveBeenCalledWith(expect.any(String), "info"); }); it("请求抛异常:status=error、弹网络异常 toast、返回 null", async () => { post.mockRejectedValue(new Error("network down")); const { result } = renderHook(() => useDeferredDraft()); let id: string | null = "sentinel"; await act(async () => { id = await result.current.ensureCreated("正文"); }); expect(id).toBeNull(); expect(result.current.status).toBe("error"); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); }); });