// @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAiConversation } from "./useAiConversation"; // 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。 const get = vi.fn(); const post = vi.fn(); const toast = vi.fn(); vi.mock("@/lib/api/client", () => ({ api: { GET: (...a: unknown[]) => get(...a), POST: (...a: unknown[]) => post(...a), }, })); vi.mock("@/components/Toast", () => ({ useToast: () => toast })); const LIST_PATH = "/projects/{project_id}/ai-messages"; function serverRow(overrides: Record = {}) { return { id: overrides.id ?? crypto.randomUUID(), project_id: "p1", chapter_no: 1, thread_id: "t1", seq: 0, kind: "refine", tool_key: null, role: "ai", content: "server", meta: {}, created_at: "2026-07-09T00:00:00Z", ...overrides, }; } describe("useAiConversation", () => { beforeEach(() => { get.mockReset(); post.mockReset(); toast.mockReset(); }); afterEach(() => vi.clearAllMocks()); it("初始 idle,未触发前不发 GET", () => { const { result } = renderHook(() => useAiConversation("p1", 1)); expect(get).not.toHaveBeenCalled(); expect(result.current.status).toBe("idle"); expect(result.current.messages).toEqual([]); }); it("ensureLoaded 懒加载一次,缓存不重复拉取", async () => { get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null }); const { result } = renderHook(() => useAiConversation("p1", 1)); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("ready")); expect(result.current.messages).toHaveLength(1); act(() => result.current.ensureLoaded()); expect(get).toHaveBeenCalledTimes(1); }); it("GET 传 chapter_no 过滤(本章 ∪ 项目级)", async () => { get.mockResolvedValue({ data: { messages: [] }, error: null }); const { result } = renderHook(() => useAiConversation("p1", 5)); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("ready")); expect(get).toHaveBeenCalledWith( LIST_PATH, expect.objectContaining({ params: expect.objectContaining({ path: { project_id: "p1" }, query: { chapter_no: 5 }, }), }), ); }); it("加载失败 → error 态 + toast,reload 可重试", async () => { get.mockResolvedValueOnce({ data: null, error: { detail: "boom" } }); const { result } = renderHook(() => useAiConversation("p1", 1)); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("error")); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); get.mockResolvedValueOnce({ data: { messages: [serverRow()] }, error: null }); act(() => result.current.reload()); await waitFor(() => expect(result.current.status).toBe("ready")); expect(result.current.messages).toHaveLength(1); }); it("appendMessages:乐观插入 → 成功后按服务器行正位对账", async () => { get.mockResolvedValue({ data: { messages: [] }, error: null }); const persisted = serverRow({ id: "srv-1", content: "refined", role: "ai" }); post.mockResolvedValue({ data: { messages: [persisted] }, error: null }); const { result } = renderHook(() => useAiConversation("p1", 1)); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("ready")); await act(async () => { await result.current.appendMessages({ threadId: "t1", kind: "refine", chapterNo: 1, messages: [{ role: "ai", content: "refined" }], }); }); expect(post).toHaveBeenCalledTimes(1); // 临时行被服务器行替换(对账后只剩真实 id)。 expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.id).toBe("srv-1"); }); it("appendMessages 失败 → 回滚乐观行 + toast,绝不 throw", async () => { get.mockResolvedValue({ data: { messages: [] }, error: null }); post.mockResolvedValue({ data: null, error: { detail: "nope" } }); const { result } = renderHook(() => useAiConversation("p1", 1)); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("ready")); await act(async () => { await expect( result.current.appendMessages({ threadId: "t1", kind: "refine", chapterNo: 1, messages: [{ role: "ai", content: "x" }], }), ).resolves.toBeUndefined(); }); expect(result.current.messages).toHaveLength(0); expect(toast).toHaveBeenCalledWith(expect.any(String), "error"); }); it("silent 模式:不 GET、不 toast,append 只 fire-and-forget POST", async () => { post.mockResolvedValue({ data: { messages: [serverRow()] }, error: null }); const { result } = renderHook(() => useAiConversation("p1", 0, { silent: true }), ); act(() => result.current.ensureLoaded()); expect(get).not.toHaveBeenCalled(); expect(result.current.status).toBe("idle"); await act(async () => { await result.current.appendMessages({ threadId: "t9", kind: "generator", toolKey: "book-title", chapterNo: null, messages: [{ role: "author", content: "brief" }], }); }); expect(post).toHaveBeenCalledTimes(1); expect(toast).not.toHaveBeenCalled(); }); it("silent 模式 append 失败也不 toast、不 throw", async () => { post.mockResolvedValue({ data: null, error: { detail: "x" } }); const { result } = renderHook(() => useAiConversation("p1", 0, { silent: true }), ); await act(async () => { await result.current.appendMessages({ threadId: "t9", kind: "generator", chapterNo: null, messages: [{ role: "author", content: "brief" }], }); }); expect(toast).not.toHaveBeenCalled(); }); it("切换项目 → 清缓存并复位(重新可加载)", async () => { get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null }); const { result, rerender } = renderHook( ({ pid }: { pid: string }) => useAiConversation(pid, 1), { initialProps: { pid: "p1" } }, ); act(() => result.current.ensureLoaded()); await waitFor(() => expect(result.current.status).toBe("ready")); rerender({ pid: "p2" }); expect(result.current.status).toBe("idle"); expect(result.current.messages).toEqual([]); }); });