// @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); }); });