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%。
This commit is contained in:
Yaojia Wang
2026-07-08 07:12:04 +02:00
parent 6ed9c89430
commit 542c2abac9
5 changed files with 567 additions and 1 deletions

View File

@@ -0,0 +1,162 @@
// @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([]);
});
});