Files
writer-work-flow/apps/web/lib/jobs/useJobPoll.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

140 lines
4.5 KiB
TypeScript
Raw Permalink 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 { 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);
});
});