Files
writer-work-flow/apps/web/lib/workbench/useChapterRewrite.test.ts
Yaojia Wang 542c2abac9 feat(frontend): 整章「再沟通/重写」面板——给意见流式重写整章,版本栈迭代,接受替换正文
WFW-8 整章级再沟通(前端)。底栏新增「整章重写」→ ChapterRewritePanel:
- 作者给意见 → useChapterRewrite 消费 POST .../rewrite 的 SSE 流(复用 lib/stream/sse
  帧解析),流式 live 展示新一版;
- 每轮完成压入版本栈,「再改一版」以最新版为 prior_draft + 新意见迭代(多轮对话式);
- 接受某版才「替换整章正文」落草稿(HITL,AI 绝不静默覆盖);停/错误/网络异常均归一处理。
四张 AI 结果卡(润色/续写/工具箱/整章重写)互斥。gen:api 重生成 TS 客户端。
门禁绿:tsc/lint/vitest 636(+7 useChapterRewrite)/build/coverage 95.36%。
2026-07-08 07:12:04 +02:00

163 lines
5.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 { 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());
await act(async () => {
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("新版正文");
});
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([]);
});
});