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,139 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useJobPoll } from "./useJobPoll";
// 后端客户端是 hook 的副作用边界状态机job.ts保留真实。
const get = vi.fn();
vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a) } }));
const POLL_INTERVAL_MS = 1500;
describe("useJobPoll", () => {
beforeEach(() => {
vi.useFakeTimers();
get.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
it("初始为 polling、进度 0、无 job", () => {
const { result } = renderHook(() => useJobPoll());
expect(result.current.status).toBe("polling");
expect(result.current.progress).toBe(0);
expect(result.current.job).toBeNull();
});
it("轮询循环running 持续轮询done 终态后停止", async () => {
// Arrange首拍 running(50),次拍 done(100)。
get
.mockResolvedValueOnce({
data: { id: "j1", status: "running", progress: 50 },
error: null,
})
.mockResolvedValueOnce({
data: { id: "j1", status: "done", progress: 100, result: {} },
error: null,
});
const { result } = renderHook(() => useJobPoll());
// Act启动轮询首拍立即执行。
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.status).toBe("polling");
expect(result.current.progress).toBe(50);
// 推进一个轮询间隔触发次拍 → 终态 done。
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
});
// Assertdone、进度 100不再继续轮询仅两次 GET
expect(result.current.status).toBe("done");
expect(result.current.progress).toBe(100);
expect(get).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3);
});
expect(get).toHaveBeenCalledTimes(2);
});
it("job failed进入 error 终态并带后端错误文案", async () => {
get.mockResolvedValue({
data: { id: "j1", status: "failed", progress: 30, error: "配额耗尽" },
error: null,
});
const { result } = renderHook(() => useJobPoll());
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.status).toBe("error");
expect(result.current.error).toBe("配额耗尽");
});
it("后端返回 error 信封dispatch 失败并显示通用文案", async () => {
get.mockResolvedValue({ data: null, error: { detail: "500" } });
const { result } = renderHook(() => useJobPoll());
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.status).toBe("error");
expect(result.current.error).toBe("轮询任务状态失败");
});
it("请求抛异常:捕获并以异常消息进入 error", async () => {
get.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useJobPoll());
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.status).toBe("error");
expect(result.current.error).toBe("network down");
});
it("reset 停止当前轮询:后续不再发起 GET", async () => {
get.mockResolvedValue({
data: { id: "j1", status: "running", progress: 10 },
error: null,
});
const { result } = renderHook(() => useJobPoll());
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
const callsBefore = get.mock.calls.length;
act(() => result.current.reset());
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
});
expect(get.mock.calls.length).toBe(callsBefore);
});
it("卸载清理:定时器被清除,不再继续轮询", async () => {
get.mockResolvedValue({
data: { id: "j1", status: "running", progress: 10 },
error: null,
});
const { result, unmount } = renderHook(() => useJobPoll());
await act(async () => {
result.current.poll("j1");
await vi.advanceTimersByTimeAsync(0);
});
const callsBefore = get.mock.calls.length;
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2);
});
expect(get.mock.calls.length).toBe(callsBefore);
});
});