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:
Yaojia Wang
2026-06-27 06:21:42 +02:00
parent a02c6b6e4f
commit a6f5d085e5
19 changed files with 2838 additions and 18 deletions

View 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();
});
});