test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测: 生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、 SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、 文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。 vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
This commit is contained in:
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
110
apps/web/lib/autosave/useAutosave.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAutosave } from "./useAutosave";
|
||||
import { AUTOSAVE_DELAY_MS } from "./autosave";
|
||||
|
||||
// 后端客户端与 Toast 是 hook 的副作用边界,单测一律 mock;防抖逻辑(autosave.ts)保留真实。
|
||||
const put = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/lib/api/client", () => ({ api: { PUT: (...a: unknown[]) => put(...a) } }));
|
||||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||||
|
||||
describe("useAutosave", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
put.mockReset();
|
||||
toast.mockReset();
|
||||
put.mockResolvedValue({ error: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("无初始草稿时初始为 idle、无标签", () => {
|
||||
// Arrange + Act
|
||||
const { result } = renderHook(() => useAutosave("p1", 1));
|
||||
// Assert
|
||||
expect(result.current.status).toBe("idle");
|
||||
expect(result.current.savedLabel).toBeNull();
|
||||
});
|
||||
|
||||
it("种入已存草稿时初始即为 saved 并显示「已保存」", () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 1, "旧草稿"));
|
||||
expect(result.current.status).toBe("saved");
|
||||
expect(result.current.savedLabel).toBe("已保存");
|
||||
});
|
||||
|
||||
it("防抖合并:快速多次改动只在静默后保存一次", async () => {
|
||||
// Arrange
|
||||
const { result } = renderHook(() => useAutosave("p1", 2));
|
||||
|
||||
// Act:连续三次输入,未到延迟前不应保存。
|
||||
act(() => {
|
||||
result.current.onChange("a");
|
||||
result.current.onChange("ab");
|
||||
result.current.onChange("abc");
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
|
||||
// Assert:只发一次 PUT,且是最后一次文本;状态落到 saved + 时间标签。
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(put).toHaveBeenCalledWith(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/draft",
|
||||
{
|
||||
params: { path: { project_id: "p1", chapter_no: 2 } },
|
||||
body: { text: "abc" },
|
||||
},
|
||||
);
|
||||
expect(result.current.status).toBe("saved");
|
||||
expect(result.current.savedLabel).toMatch(/^保存于 \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("flush 立即触发待保存项,无需等待延迟", async () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 3));
|
||||
act(() => result.current.onChange("即时"));
|
||||
await act(async () => {
|
||||
result.current.flush();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("文本与已存基线相同时早退、不发请求", async () => {
|
||||
const { result } = renderHook(() => useAutosave("p1", 4, "基线"));
|
||||
act(() => result.current.onChange("基线"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe("saved");
|
||||
});
|
||||
|
||||
it("后端返回 error:状态回滚为 error 并弹 toast", async () => {
|
||||
put.mockResolvedValue({ error: { detail: "boom" } });
|
||||
const { result } = renderHook(() => useAutosave("p1", 5));
|
||||
act(() => result.current.onChange("变更"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS);
|
||||
});
|
||||
expect(result.current.status).toBe("error");
|
||||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
|
||||
it("卸载时取消待保存定时器,不再发请求", async () => {
|
||||
const { result, unmount } = renderHook(() => useAutosave("p1", 6));
|
||||
act(() => result.current.onChange("待保存"));
|
||||
unmount();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(AUTOSAVE_DELAY_MS * 2);
|
||||
});
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user