Files
writer-work-flow/apps/web/lib/autosave/useAutosave.test.ts
Yaojia Wang a6f5d085e5 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 同步说明。
2026-06-27 06:21:42 +02:00

111 lines
3.9 KiB
TypeScript
Raw 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 { 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();
});
});