Files
writer-work-flow/apps/web/lib/start/useDeferredDraft.test.ts
Yaojia Wang 8389572cbb feat(frontend): 进来即写——落地页「直接开始写」直达空白编辑器,敲字后延迟落库
Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后
才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库
非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。
落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
2026-07-07 18:18:18 +02:00

135 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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");
});
});