useRefine.refine/recommunicate→RefineVersion|null、useContinue.generate→string|null、 useChapterRewrite.send→RewriteVersion|null、useGenerator.generate→GeneratorArtifact|null。 向后兼容(既有调用忽略返回值不受影响);各 hook 成功用例补返回值断言。 面板据此 if(artifact) 才 append,空候选/错误路径不留痕。
166 lines
5.3 KiB
TypeScript
166 lines
5.3 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());
|
||
let returned: unknown;
|
||
await act(async () => {
|
||
returned = 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("新版正文");
|
||
// 追加断言: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("网络抛异常(非 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([]);
|
||
});
|
||
});
|