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%。
163 lines
5.1 KiB
TypeScript
163 lines
5.1 KiB
TypeScript
// @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([]);
|
||
});
|
||
});
|