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:
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal file
139
apps/web/lib/jobs/useJobPoll.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
// Assert:done、进度 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user