// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useReviewStream } from "./useReviewStream"; // 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("useReviewStream", () => { beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); it("初始为 idle、各结果集为空、未在审稿", () => { const { result } = renderHook(() => useReviewStream()); expect(result.current.state.phase).toBe("idle"); expect(result.current.state.conflicts).toEqual([]); expect(result.current.state.sections).toEqual([]); expect(result.current.isReviewing).toBe(false); }); it("流式累积四类结果:section/conflict/foreshadow/pace/style 折叠进状态", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:section\ndata:{"name":"一致性","status":"started"}\n\n', 'event:section\ndata:{"name":"一致性","status":"done"}\n\n', 'event:conflict\ndata:{"type":"性格漂移","where":"第3段","suggestion":"改回冷静","refs":["c1"]}\n\n', 'event:foreshadow\ndata:{"kind":"planted","title":"神秘信物","code":"F1"}\n\n', 'event:pace\ndata:{"water":[{"where":"开头","reason":"铺垫过长"}],"hook":true,"beat_map":[1,2,3]}\n\n', 'event:style\ndata:{"score":0.82,"segments":[{"idx":0,"text":"原文","score":0.5,"label":"偏离"}]}\n\n', ]), ); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "草稿正文"); }); const s = result.current.state; // section 按 name upsert:同名只留最后状态。 expect(s.sections).toEqual([{ name: "一致性", status: "done" }]); expect(s.conflicts).toHaveLength(1); expect(s.conflicts[0]).toMatchObject({ type: "性格漂移", where: "第3段" }); expect(s.foreshadow).toHaveLength(1); expect(s.foreshadow[0]).toMatchObject({ kind: "planted", title: "神秘信物" }); expect(s.pace).toMatchObject({ hook: true, beat_map: [1, 2, 3] }); expect(s.style?.score).toBe(0.82); expect(s.style?.segments[0]).toMatchObject({ idx: 0, text: "原文" }); }); it("以 JSON body POST 当前草稿正文", async () => { fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n'])); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 7, "需要重审的草稿"); }); const [url, init] = fetchMock.mock.calls[0]!; expect(String(url)).toContain("/projects/p1/chapters/7/review"); expect(init.method).toBe("POST"); expect(JSON.parse(init.body as string)).toEqual({ draft: "需要重审的草稿" }); }); it("收到 done 帧后 phase 走到 done", async () => { fetchMock.mockResolvedValue( sseResponse([ 'event:conflict\ndata:{"type":"设定违例","where":"第1段","suggestion":"补设定"}\n\n', 'event:done\ndata:{"length":120}\n\n', ]), ); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("done"); expect(result.current.state.conflicts).toHaveLength(1); }); it("收到 error 帧:phase=error 且带错误码与文案", async () => { fetchMock.mockResolvedValue( sseResponse(['event:error\ndata:{"code":"TIMEOUT","message":"审稿超时"}\n\n']), ); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "TIMEOUT", message: "审稿超时", }); }); it("流前错误(!res.ok):解析 JSON 信封提取错误码与文案", async () => { fetchMock.mockResolvedValue( new Response( JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }), { status: 503 }, ), ); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE", message: "无可用凭据", }); }); it("流前错误且非 JSON 信封:沿用默认 REVIEW_FAILED 文案", async () => { fetchMock.mockResolvedValue(new Response("oops", { status: 500 })); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error?.code).toBe("REVIEW_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(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("error"); expect(result.current.state.error).toMatchObject({ code: "NETWORK", message: "connection reset", }); }); it("非 Error 抛出:回退为未知网络错误文案", async () => { fetchMock.mockRejectedValue(42); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.error?.message).toBe("未知网络错误"); }); it("AbortError 被吞掉:不进入 error,phase 维持 reviewing", async () => { fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError")); const { result } = renderHook(() => useReviewStream()); await act(async () => { await result.current.start("p1", 1, "draft"); }); expect(result.current.state.phase).toBe("reviewing"); expect(result.current.state.error).toBeNull(); }); it("stop() 主动停止:abort 连接并将 phase 置 aborted", async () => { const hangingBody = new ReadableStream({}); fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 })); const { result } = renderHook(() => useReviewStream()); act(() => { void result.current.start("p1", 1, "draft"); }); await act(async () => { result.current.stop(); }); expect(result.current.state.phase).toBe("aborted"); expect(result.current.isReviewing).toBe(false); }); it("seed() 用历史留痕种入状态(无需重审即可查看)", () => { const { result } = renderHook(() => useReviewStream()); const seedData = { conflicts: [ { type: "时间线倒错", where: "第2段", refs: [], suggestion: "对齐时间", original: null, replacement: null, }, ], foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }], pace: { water: [], hook: false, beat_map: [1] }, style: { score: 0.9, segments: [] }, characterization: { issues: [ { character: "沈砚", aspect: "动机一致性", where: "第3段", quote: "他忽然转身。", diagnosis: "动机断裂", suggestion: "扣回护妹动机", confidence: 0.8, }, ], }, }; act(() => result.current.seed(seedData)); expect(result.current.state.conflicts).toEqual(seedData.conflicts); expect(result.current.state.foreshadow).toEqual(seedData.foreshadow); expect(result.current.state.pace).toEqual(seedData.pace); expect(result.current.state.style).toEqual(seedData.style); expect(result.current.state.characterization).toEqual( seedData.characterization, ); expect(result.current.state.phase).toBe("idle"); }); });