新增 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 同步说明。
140 lines
4.5 KiB
TypeScript
140 lines
4.5 KiB
TypeScript
// @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);
|
||
});
|
||
});
|