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:
162
apps/web/lib/workbench/useChapterRewrite.test.ts
Normal file
162
apps/web/lib/workbench/useChapterRewrite.test.ts
Normal 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";
|
||||
|
||||
// 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("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_draft(snake_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("网络抛异常(非 Abort):phase=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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user