Files
writer-work-flow/apps/web/lib/stream/useDraftStream.test.ts
Yaojia Wang a6f5d085e5 test: 接入 jsdom 测试栈, 把 15 个 React hooks 纳入 80% 覆盖率门禁
新增 jsdom + @testing-library/react, 为 lib/** 全部 use*.ts hooks 写 renderHook 单测:
生成/CRUD(world/character/generator/outline/accept/foreshadow/rules)、
SSE流(draftStream/reviewStream)、定时轮询(autosave/jobPoll/kimiOauth)、
文风与注入(refine/styleLearn/injection)。覆盖率 mock api 客户端+Toast, 不打真实 LLM。
vitest.config.ts 去掉 use*.ts 排除; 前端覆盖率 63%→95%。CLAUDE.md 同步说明。
2026-06-27 06:21:42 +02:00

221 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStream } from "./useDraftStream";
// fetchSSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
const fetchMock = vi.fn();
// 把若干 SSE 文本块封进 ReadableStream模拟后端逐块下发的 chunked body。
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
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("<html>oops</html>", { 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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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<Uint8Array>({});
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("已落草稿");
});
});