// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStream } from "./useDraftStream"; // fetch(SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。 const fetchMock = vi.fn(); // 把若干 SSE 文本块封进 ReadableStream,模拟后端逐块下发的 chunked body。 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(); }, }); } // 构造一个流式成功响应(200 + text/event-stream body)。 function sseResponse(chunks: string[]): Response { return new Response(sseStream(chunks), { status: 200, headers: { "Content-Type": "text/event-stream" }, }); } describe("useDraftStream", () => { beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); it("初始为 idle、空文本、未在流式", () => { const { result } = renderHook(() => useDraftStream()); expect(result.current.state.phase).toBe("idle"); expect(result.current.state.text).toBe(""); expect(result.current.isStreaming).toBe(false); }); it("流式累积 token:多个 token 帧拼成完整文本", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:token\ndata:{"text":"你好"}\n\n', 'event:token\ndata:{"text":",世界"}\n\n', ]), ); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.text).toBe("你好,世界"); expect(result.current.state.error).toBeNull(); }); it("收到 done 帧后 phase 走到 done", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:token\ndata:{"text":"abc"}\n\n', 'event:done\ndata:{"length":3}\n\n', ]), ); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("done"); expect(result.current.state.text).toBe("abc"); }); it("带本章指令时以 JSON body POST 指令", async () => { fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n'])); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 2, " 写得热血一点 "); }); const [, init] = fetchMock.mock.calls[0]; expect(init.method).toBe("POST"); expect(init.headers["Content-Type"]).toBe("application/json"); expect(JSON.parse(init.body as string)).toEqual({ directive: "写得热血一点" }); }); it("指令为空白时退回裸 POST(不带 JSON body)", async () => { fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n'])); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 2, " "); }); const [, init] = fetchMock.mock.calls[0]; expect(init.body).toBeUndefined(); expect(init.headers["Content-Type"]).toBeUndefined(); }); it("收到 error 帧:phase=error 且带错误码与文案", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n', ]), ); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "RATE_LIMIT", message: "配额不足", }); }); it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => { fetchMock.mockResolvedValue( new Response( JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }), { status: 503 }, ), ); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE", message: "无可用凭据", }); }); it("流前错误且非 JSON 信封:沿用默认 STREAM_FAILED 文案", async () => { fetchMock.mockResolvedValue(new Response("oops", { status: 500 })); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error?.code).toBe("STREAM_FAILED"); expect(result.current.state.error?.message).toContain("500"); }); it("网络抛异常(非 Abort):phase=error 且 code=NETWORK", async () => { fetchMock.mockRejectedValue(new Error("connection reset")); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "NETWORK", message: "connection reset", }); }); it("非 Error 抛出:回退为未知网络错误文案", async () => { fetchMock.mockRejectedValue("boom-string"); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.error?.message).toBe("未知网络错误"); }); it("AbortError 被吞掉:不进入 error,phase 维持 streaming", async () => { fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError")); const { result } = renderHook(() => useDraftStream()); await act(async () => { await result.current.start("p1", 1); }); expect(result.current.state.phase).toBe("streaming"); expect(result.current.state.error).toBeNull(); }); it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => { // 用一个永不结束的 body,让流挂起后被 stop 中断(已生成部分保留)。 const hangingBody = new ReadableStream({}); fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 })); const { result } = renderHook(() => useDraftStream()); act(() => { void result.current.start("p1", 1); }); await act(async () => { result.current.stop(); }); expect(result.current.state.phase).toBe("aborted"); expect(result.current.isStreaming).toBe(false); }); it("reset(text) 用给定文本重置回 idle", () => { const { result } = renderHook(() => useDraftStream()); act(() => result.current.reset("已落草稿")); expect(result.current.state.phase).toBe("idle"); expect(result.current.state.text).toBe("已落草稿"); }); });