feat(frontend): 进来即写——落地页「直接开始写」直达空白编辑器,敲字后延迟落库
Phase 0(写作工作台重构):新增 /write 草稿编辑器入口,只有敲入实质正文并停顿后 才懒建占位「未命名草稿」项目并种入首章草稿(useDeferredDraft,双重幂等闸 + 草稿落库 非致命降级),随即 replace 换入完整工作台;空进空出不落库,杜绝孤儿草稿。 落地页 CTA 改为「直接开始写」为主、「先立项」(立项向导)为辅。
This commit is contained in:
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
134
apps/web/lib/start/useDeferredDraft.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// @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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user