新增 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 同步说明。
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
// @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();
|
||
});
|
||
});
|