Files
writer-work-flow/apps/web/lib/review/useReviewStream.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

236 lines
8.2 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 { useReviewStream } from "./useReviewStream";
// fetch审稿 SSE 流)是 hook 的外部副作用边界,单测一律 stub 全局 fetch。
const fetchMock = vi.fn();
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();
},
});
}
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("网络抛异常(非 Abortphase=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 被吞掉:不进入 errorphase 维持 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<Uint8Array>({});
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: [] },
};
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.phase).toBe("idle");
});
});