// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useContextDrawerData } from "./useContextDrawer"; // 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。 const get = vi.fn(); const toast = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a) }, })); vi.mock("@/components/Toast", () => ({ useToast: () => toast })); const CODEX_PATH = "/projects/{project_id}/world_entities"; const OUTLINE_PATH = "/projects/{project_id}/outline"; // 按路径分流 codex / outline 两个端点的 mock 响应。 function routeGet( responses: { codex?: unknown; outline?: unknown; codexReject?: boolean; outlineReject?: boolean; } = {}, ): void { get.mockImplementation((path: string) => { if (path === CODEX_PATH) { if (responses.codexReject) return Promise.reject(new Error("net")); return Promise.resolve(responses.codex ?? { data: null, error: null }); } if (path === OUTLINE_PATH) { if (responses.outlineReject) return Promise.reject(new Error("net")); return Promise.resolve(responses.outline ?? { data: null, error: null }); } return Promise.resolve({ data: null, error: null }); }); } describe("useContextDrawerData", () => { beforeEach(() => { get.mockReset(); toast.mockReset(); }); afterEach(() => vi.clearAllMocks()); it("抽屉关闭时不发请求,两资源保持 idle", () => { routeGet(); const { result } = renderHook(() => useContextDrawerData("p1", "codex", false), ); expect(get).not.toHaveBeenCalled(); expect(result.current.codex.status).toBe("idle"); expect(result.current.outline.status).toBe("idle"); }); it("打开且在设定库 Tab:拉取并归组到 ready", async () => { routeGet({ codex: { data: { world_entities: [ { type: "势力", name: "雾港议会", rules: [] }, { type: "地理", name: "北境", rules: [] }, ], }, error: null, }, }); const { result } = renderHook(() => useContextDrawerData("p1", "codex", true), ); await waitFor(() => expect(result.current.codex.status).toBe("ready")); expect(result.current.codex.data.map((g) => g.type)).toEqual([ "势力", "地理", ]); expect(get).toHaveBeenCalledTimes(1); expect(toast).not.toHaveBeenCalled(); }); it("打开且在大纲 Tab:拉取并按章号升序整形", async () => { routeGet({ outline: { data: { chapters: [ { no: 2, volume: 1, beats: ["决战"], foreshadow_windows: [] }, { no: 1, volume: 1, beats: ["开场"], foreshadow_windows: [] }, ], }, error: null, }, }); const { result } = renderHook(() => useContextDrawerData("p1", "outline", true), ); await waitFor(() => expect(result.current.outline.status).toBe("ready")); expect(result.current.outline.data.map((r) => r.no)).toEqual([1, 2]); }); it("data 为空时 ready 且为空数组", async () => { routeGet({ codex: { data: { world_entities: null }, error: null } }); const { result } = renderHook(() => useContextDrawerData("p1", "codex", true), ); await waitFor(() => expect(result.current.codex.status).toBe("ready")); expect(result.current.codex.data).toEqual([]); }); it("后端返回 error:error 态 + 错误 toast", async () => { routeGet({ codex: { data: null, error: { detail: "boom" } } }); const { result } = renderHook(() => useContextDrawerData("p1", "codex", true), ); await waitFor(() => expect(result.current.codex.status).toBe("error")); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); }); it("请求抛异常:error 态 + 网络异常 toast", async () => { routeGet({ codexReject: true }); const { result } = renderHook(() => useContextDrawerData("p1", "codex", true), ); await waitFor(() => expect(result.current.codex.status).toBe("error")); expect(toast).toHaveBeenCalledWith( "设定库加载异常,请检查网络。", "error", ); }); it("已加载后切走再切回不重复拉取(缓存)", async () => { routeGet({ codex: { data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] }, error: null, }, }); const { result, rerender } = renderHook( ({ tab }: { tab: "codex" | "outline" }) => useContextDrawerData("p1", tab, true), { initialProps: { tab: "codex" } as { tab: "codex" | "outline" } }, ); await waitFor(() => expect(result.current.codex.status).toBe("ready")); rerender({ tab: "outline" }); rerender({ tab: "codex" }); expect(get.mock.calls.filter((c) => c[0] === CODEX_PATH)).toHaveLength(1); }); it("reloadCodex 在出错后可重试拉取", async () => { routeGet({ codex: { data: null, error: { detail: "boom" } } }); const { result } = renderHook(() => useContextDrawerData("p1", "codex", true), ); await waitFor(() => expect(result.current.codex.status).toBe("error")); routeGet({ codex: { data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] }, error: null, }, }); act(() => result.current.reloadCodex()); await waitFor(() => expect(result.current.codex.status).toBe("ready")); expect(result.current.codex.data).toHaveLength(1); }); });