Files
writer-work-flow/apps/web/lib/workbench/useChapterRewrite.test.ts
Yaojia Wang 59f2f60a7e refactor(frontend): 四生成 hook 结束后额外返回产物(AC-3 留痕前置)
useRefine.refine/recommunicate→RefineVersion|null、useContinue.generate→string|null、
useChapterRewrite.send→RewriteVersion|null、useGenerator.generate→GeneratorArtifact|null。
向后兼容(既有调用忽略返回值不受影响);各 hook 成功用例补返回值断言。
面板据此 if(artifact) 才 append,空候选/错误路径不留痕。
2026-07-09 17:15:49 +02:00

166 lines
5.3 KiB
TypeScript
Raw Permalink 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 { useChapterRewrite } from "./useChapterRewrite";
// fetchSSE 流)是 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("useChapterRewrite", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("初始为 idle、无版本", () => {
const { result } = renderHook(() => useChapterRewrite());
expect(result.current.state.phase).toBe("idle");
expect(result.current.versions).toEqual([]);
expect(result.current.latest).toBeNull();
});
it("重写完成token+done流式累积正文并压入版本栈带作者意见", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"新版"}\n\n',
'event:token\ndata:{"text":"正文"}\n\n',
'event:done\ndata:{"length":4}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
let returned: unknown;
await act(async () => {
returned = await result.current.send("p1", 3, "节奏太慢", "旧草稿");
});
// POST body 带 feedback + prior_draftsnake_case
const call = fetchMock.mock.calls[0];
expect(call?.[0]).toContain("/projects/p1/chapters/3/rewrite");
expect(JSON.parse((call?.[1]?.body as string) ?? "")).toEqual({
feedback: "节奏太慢",
prior_draft: "旧草稿",
});
expect(result.current.state.phase).toBe("done");
expect(result.current.versions).toEqual([
{ feedback: "节奏太慢", text: "新版正文" },
]);
expect(result.current.latest?.text).toBe("新版正文");
// 追加断言send 返回本版(供 ChapterRewritePanel 结束后留痕)。
expect(returned).toEqual({ feedback: "节奏太慢", text: "新版正文" });
});
it("多轮迭代第二轮再压一版latest 为最新版", async () => {
fetchMock
.mockResolvedValueOnce(
sseResponse([
'event:token\ndata:{"text":"第一版"}\n\n',
'event:done\ndata:{"length":3}\n\n',
]),
)
.mockResolvedValueOnce(
sseResponse([
'event:token\ndata:{"text":"第二版"}\n\n',
'event:done\ndata:{"length":3}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "意见一", "原草稿");
});
await act(async () => {
await result.current.send("p1", 1, "意见二", "第一版");
});
expect(result.current.versions).toHaveLength(2);
expect(result.current.latest).toEqual({ feedback: "意见二", text: "第二版" });
});
it("error 帧phase=error 且不入版本栈", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:error\ndata:{"code":"RATE_LIMIT","message":"配额不足"}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({ code: "RATE_LIMIT" });
expect(result.current.versions).toEqual([]);
});
it("流前错误(!res.ok解析 JSON 信封提取错误码", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
{ status: 503 },
),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.error).toMatchObject({ code: "LLM_UNAVAILABLE" });
expect(result.current.versions).toEqual([]);
});
it("网络抛异常(非 Abortphase=error 且 code=NETWORK", async () => {
fetchMock.mockRejectedValue(new Error("connection reset"));
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
expect(result.current.state.error).toMatchObject({
code: "NETWORK",
message: "connection reset",
});
});
it("reset 清回 idle 与空版本栈", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:token\ndata:{"text":"x"}\n\n',
'event:done\ndata:{"length":1}\n\n',
]),
);
const { result } = renderHook(() => useChapterRewrite());
await act(async () => {
await result.current.send("p1", 1, "改", "草稿");
});
act(() => result.current.reset());
expect(result.current.state.phase).toBe("idle");
expect(result.current.versions).toEqual([]);
});
});