// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useChapterRewrite } from "./useChapterRewrite"; // fetch(SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。 const fetchMock = vi.fn(); function sseStream(chunks: string[]): ReadableStream { const enc = new TextEncoder(); return new ReadableStream({ start(c) { for (const ch of chunks) c.enqueue(enc.encode(ch)); c.close(); }, }); } function sseResponse(chunks: string[]): Response { return new Response(sseStream(chunks), { status: 200, headers: { "Content-Type": "text/event-stream" }, }); } describe("useChapterRewrite", () => { beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); it("初始为 idle、无版本", () => { const { result } = renderHook(() => useChapterRewrite()); expect(result.current.state.phase).toBe("idle"); expect(result.current.versions).toEqual([]); expect(result.current.latest).toBeNull(); }); it("重写完成(token+done):流式累积正文并压入版本栈(带作者意见)", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:token\ndata:{"text":"新版"}\n\n', 'event:token\ndata:{"text":"正文"}\n\n', 'event:done\ndata:{"length":4}\n\n', ]), ); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 3, "节奏太慢", "旧草稿"); }); // POST body 带 feedback + prior_draft(snake_case)。 const call = fetchMock.mock.calls[0]; expect(call?.[0]).toContain("/projects/p1/chapters/3/rewrite"); expect(JSON.parse((call?.[1]?.body as string) ?? "")).toEqual({ feedback: "节奏太慢", prior_draft: "旧草稿", }); expect(result.current.state.phase).toBe("done"); expect(result.current.versions).toEqual([ { feedback: "节奏太慢", text: "新版正文" }, ]); expect(result.current.latest?.text).toBe("新版正文"); }); it("多轮迭代:第二轮再压一版,latest 为最新版", async () => { fetchMock .mockResolvedValueOnce( sseResponse([ 'event:token\ndata:{"text":"第一版"}\n\n', 'event:done\ndata:{"length":3}\n\n', ]), ) .mockResolvedValueOnce( sseResponse([ 'event:token\ndata:{"text":"第二版"}\n\n', 'event:done\ndata:{"length":3}\n\n', ]), ); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 1, "意见一", "原草稿"); }); await act(async () => { await result.current.send("p1", 1, "意见二", "第一版"); }); expect(result.current.versions).toHaveLength(2); expect(result.current.latest).toEqual({ feedback: "意见二", text: "第二版" }); }); it("error 帧:phase=error 且不入版本栈", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n', ]), ); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 1, "改", "草稿"); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "RATE_LIMIT" }); expect(result.current.versions).toEqual([]); }); it("流前错误(!res.ok):解析 JSON 信封提取错误码", async () => { fetchMock.mockResolvedValue( new Response( JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }), { status: 503 }, ), ); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 1, "改", "草稿"); }); expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE" }); expect(result.current.versions).toEqual([]); }); it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => { fetchMock.mockRejectedValue(new Error("connection reset")); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 1, "改", "草稿"); }); expect(result.current.state.error).toMatchObject({ code: "NETWORK", message: "connection reset", }); }); it("reset 清回 idle 与空版本栈", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:token\ndata:{"text":"x"}\n\n', 'event:done\ndata:{"length":1}\n\n', ]), ); const { result } = renderHook(() => useChapterRewrite()); await act(async () => { await result.current.send("p1", 1, "改", "草稿"); }); act(() => result.current.reset()); expect(result.current.state.phase).toBe("idle"); expect(result.current.versions).toEqual([]); }); });