// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useInjection } from "./useInjection"; import type { InjectionResponse } from "./injection"; // api 客户端是 hook 的外部副作用边界;GET 拉确定性结果,PUT 写覆盖。单测一律 mock。 const get = vi.fn(); const put = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a), PUT: (...a: unknown[]) => put(...a), }, })); // 最小 InjectionResponse 桩(仅覆盖纯逻辑读取的字段)。 function makeResponse(over: Partial = {}): InjectionResponse { return { pinned: [], excluded: [], recent_n: 3, entities: [], ...over, } as InjectionResponse; } const CHAR = { kind: "character", name: "张三" } as const; describe("useInjection", () => { beforeEach(() => { get.mockReset(); put.mockReset(); }); afterEach(() => vi.clearAllMocks()); it("挂载成功:拉到确定性结果并落到 data,loading 归位", async () => { const body = makeResponse({ recent_n: 5 }); get.mockResolvedValue({ data: body, error: null }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.data).toEqual(body); expect(result.current.loadError).toBeNull(); expect(result.current.saveError).toBeNull(); }); it("挂载后端返回 error:给可读文案、data 置空", async () => { get.mockResolvedValue({ data: null, error: { detail: "boom" } }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.loadError).toBe("注入信息暂不可用"); expect(result.current.data).toBeNull(); }); it("挂载网络层抛错:捕获并给可读文案", async () => { get.mockRejectedValue(new Error("network down")); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.loadError).toBe("注入信息暂不可用"); expect(result.current.data).toBeNull(); }); it("togglePin 成功:PUT 覆盖后以服务端确定结果回放 data", async () => { get.mockResolvedValue({ data: makeResponse(), error: null }); const updated = makeResponse({ pinned: [{ ...CHAR }] }); put.mockResolvedValue({ data: updated, error: null }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.togglePin(CHAR); }); expect(put).toHaveBeenCalledTimes(1); expect(result.current.data).toEqual(updated); expect(result.current.saving).toBe(false); expect(result.current.saveError).toBeNull(); }); it("exclude / restore / setRecentN 各自经 PUT 提交覆盖", async () => { get.mockResolvedValue({ data: makeResponse(), error: null }); put.mockResolvedValue({ data: makeResponse(), error: null }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.exclude(CHAR); }); await act(async () => { await result.current.restore(CHAR); }); await act(async () => { await result.current.setRecentN(8); }); expect(put).toHaveBeenCalledTimes(3); // 最后一次 setRecentN 的 body.recent_n 已钳到合法区间。 expect(put).toHaveBeenLastCalledWith( "/projects/{project_id}/chapters/{chapter_no}/injection", expect.objectContaining({ body: expect.objectContaining({ recent_n: 8 }) }), ); }); it("保存后端返回 error:给可读文案、本地 data 不变(天然回滚)", async () => { const loaded = makeResponse({ recent_n: 4 }); get.mockResolvedValue({ data: loaded, error: null }); put.mockResolvedValue({ data: null, error: { detail: "422" } }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.togglePin(CHAR); }); expect(result.current.saveError).toBe("保存注入设置失败,请重试"); expect(result.current.data).toEqual(loaded); }); it("保存网络层抛错:本地状态不动、给可读文案、不抛", async () => { const loaded = makeResponse(); get.mockResolvedValue({ data: loaded, error: null }); put.mockRejectedValue(new Error("network down")); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.togglePin(CHAR); }); expect(result.current.saveError).toBe("保存注入设置失败,请重试"); expect(result.current.data).toEqual(loaded); }); it("保存失败不清空 data,且 loadError 与 saveError 互不影响", async () => { const loaded = makeResponse({ recent_n: 4 }); get.mockResolvedValue({ data: loaded, error: null }); put.mockResolvedValue({ data: null, error: { detail: "422" } }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); // 加载成功 → loadError 为空。 expect(result.current.loadError).toBeNull(); await act(async () => { await result.current.togglePin(CHAR); }); // 保存失败只设 saveError;data 与 loadError 不受影响。 expect(result.current.saveError).toBe("保存注入设置失败,请重试"); expect(result.current.loadError).toBeNull(); expect(result.current.data).toEqual(loaded); // clearSaveError 关闭悬浮提示,data 仍在。 act(() => result.current.clearSaveError()); expect(result.current.saveError).toBeNull(); expect(result.current.data).toEqual(loaded); }); it("retrySave 重放上一次失败的覆盖,成功后回放 data、清空 saveError", async () => { const loaded = makeResponse({ recent_n: 4 }); get.mockResolvedValue({ data: loaded, error: null }); put.mockResolvedValueOnce({ data: null, error: { detail: "422" } }); const updated = makeResponse({ recent_n: 4, pinned: [{ ...CHAR }] }); put.mockResolvedValueOnce({ data: updated, error: null }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.togglePin(CHAR); }); expect(result.current.saveError).toBe("保存注入设置失败,请重试"); await act(async () => { await result.current.retrySave(); }); expect(put).toHaveBeenCalledTimes(2); expect(result.current.saveError).toBeNull(); expect(result.current.data).toEqual(updated); }); it("data 未就绪时 mutate 早返回、不发 PUT", async () => { get.mockResolvedValue({ data: null, error: { detail: "fail" } }); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.togglePin(CHAR); }); expect(put).not.toHaveBeenCalled(); }); it("并发锁:保存在途时再次触发被拦下,只发一次 PUT", async () => { get.mockResolvedValue({ data: makeResponse(), error: null }); // 第一次 PUT 挂起,制造在途窗口。 let resolveFirst: (v: unknown) => void = () => {}; put.mockImplementationOnce( () => new Promise((res) => { resolveFirst = res; }), ); const { result } = renderHook(() => useInjection("p1", 3)); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { // 不 await 第一次:保存在途时立即触发第二次,应被 savingRef 拦下。 const first = result.current.togglePin(CHAR); await result.current.exclude(CHAR); resolveFirst({ data: makeResponse(), error: null }); await first; }); expect(put).toHaveBeenCalledTimes(1); }); });