新增 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 同步说明。
221 lines
7.1 KiB
TypeScript
221 lines
7.1 KiB
TypeScript
// @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<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("网络抛异常(非 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<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("已落草稿");
|
||
});
|
||
});
|