Files
writer-work-flow/apps/web/lib/review/useReviewStream.test.ts
Yaojia Wang 72b3c81146 feat(review): 人物塑造 advisory 审(第五审,仅建议不阻断验收)
写→审链新增第五审「人物塑造」(灵感③/D2):advisory 单维——仅建议、不阻断
验收(不进 conflicts、不碰 assert_conflicts_resolved、不入 REVIEW_RESERVED_NAMES);
只做人物塑造、不做氛围。

- SPEC characterization_spec(analyst,reads=("characters",),writes=())+
  prompts/characterization.md(只读、显式忽略文本长度与辞藻华丽度、引用逐字片段+置信度)
- schema CharacterizationReview{issues[...]}(全字段默认值守解析韧性)+ 注册 catalog
- 计数三处 22→23 + regen prompt_hashes.json(仅加一条)
- DB chapter_reviews 加 characterization JSONB nullable 列(迁移 f6a7b8c9d0e1)
- 接线:graph REVIEW_SPECS 追加 · chain ReviewRecordRepo · collect(extract/record) ·
  review_repo(Protocol/Sql/_to_view/ReviewView) · sse(event/factory/section) · normalize 自动纳入
- 契约 ReviewHistoryItem 加 characterization(gen:api 已重生成)
- 前端 sse/history/useReviewStream + CharacterizationPanel(只展示无裁决 UI、置信度降序+低置信折叠)+ ReviewReport 挂面板
- 测试 test_characterization_does_not_block_accept + 计数/golden + collect/normalize + 前端 reducer/normalize/parse

守不变量 #2/#3/#9;依赖 ⑧(motive/appearance 作客观锚点)。
2026-07-06 17:08:38 +02:00

252 lines
8.6 KiB
TypeScript
Raw Permalink 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 { useReviewStream } from "./useReviewStream";
// 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("useReviewStream", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("初始为 idle、各结果集为空、未在审稿", () => {
const { result } = renderHook(() => useReviewStream());
expect(result.current.state.phase).toBe("idle");
expect(result.current.state.conflicts).toEqual([]);
expect(result.current.state.sections).toEqual([]);
expect(result.current.isReviewing).toBe(false);
});
it("流式累积四类结果section/conflict/foreshadow/pace/style 折叠进状态", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:section\ndata:{"name":"一致性","status":"started"}\n\n',
'event:section\ndata:{"name":"一致性","status":"done"}\n\n',
'event:conflict\ndata:{"type":"性格漂移","where":"第3段","suggestion":"改回冷静","refs":["c1"]}\n\n',
'event:foreshadow\ndata:{"kind":"planted","title":"神秘信物","code":"F1"}\n\n',
'event:pace\ndata:{"water":[{"where":"开头","reason":"铺垫过长"}],"hook":true,"beat_map":[1,2,3]}\n\n',
'event:style\ndata:{"score":0.82,"segments":[{"idx":0,"text":"原文","score":0.5,"label":"偏离"}]}\n\n',
]),
);
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "草稿正文");
});
const s = result.current.state;
// section 按 name upsert同名只留最后状态。
expect(s.sections).toEqual([{ name: "一致性", status: "done" }]);
expect(s.conflicts).toHaveLength(1);
expect(s.conflicts[0]).toMatchObject({ type: "性格漂移", where: "第3段" });
expect(s.foreshadow).toHaveLength(1);
expect(s.foreshadow[0]).toMatchObject({ kind: "planted", title: "神秘信物" });
expect(s.pace).toMatchObject({ hook: true, beat_map: [1, 2, 3] });
expect(s.style?.score).toBe(0.82);
expect(s.style?.segments[0]).toMatchObject({ idx: 0, text: "原文" });
});
it("以 JSON body POST 当前草稿正文", async () => {
fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 7, "需要重审的草稿");
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(String(url)).toContain("/projects/p1/chapters/7/review");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({ draft: "需要重审的草稿" });
});
it("收到 done 帧后 phase 走到 done", async () => {
fetchMock.mockResolvedValue(
sseResponse([
'event:conflict\ndata:{"type":"设定违例","where":"第1段","suggestion":"补设定"}\n\n',
'event:done\ndata:{"length":120}\n\n',
]),
);
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("done");
expect(result.current.state.conflicts).toHaveLength(1);
});
it("收到 error 帧phase=error 且带错误码与文案", async () => {
fetchMock.mockResolvedValue(
sseResponse(['event:error\ndata:{"code":"TIMEOUT","message":"审稿超时"}\n\n']),
);
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "TIMEOUT",
message: "审稿超时",
});
});
it("流前错误(!res.ok解析 JSON 信封提取错误码与文案", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
{ status: 503 },
),
);
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "LLM_UNAVAILABLE",
message: "无可用凭据",
});
});
it("流前错误且非 JSON 信封:沿用默认 REVIEW_FAILED 文案", async () => {
fetchMock.mockResolvedValue(new Response("oops", { status: 500 }));
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error?.code).toBe("REVIEW_FAILED");
expect(result.current.state.error?.message).toContain("500");
});
it("网络抛异常(非 Abortphase=error 且 code=NETWORK", async () => {
fetchMock.mockRejectedValue(new Error("connection reset"));
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("error");
expect(result.current.state.error).toMatchObject({
code: "NETWORK",
message: "connection reset",
});
});
it("非 Error 抛出:回退为未知网络错误文案", async () => {
fetchMock.mockRejectedValue(42);
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.error?.message).toBe("未知网络错误");
});
it("AbortError 被吞掉:不进入 errorphase 维持 reviewing", async () => {
fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));
const { result } = renderHook(() => useReviewStream());
await act(async () => {
await result.current.start("p1", 1, "draft");
});
expect(result.current.state.phase).toBe("reviewing");
expect(result.current.state.error).toBeNull();
});
it("stop() 主动停止abort 连接并将 phase 置 aborted", async () => {
const hangingBody = new ReadableStream<Uint8Array>({});
fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));
const { result } = renderHook(() => useReviewStream());
act(() => {
void result.current.start("p1", 1, "draft");
});
await act(async () => {
result.current.stop();
});
expect(result.current.state.phase).toBe("aborted");
expect(result.current.isReviewing).toBe(false);
});
it("seed() 用历史留痕种入状态(无需重审即可查看)", () => {
const { result } = renderHook(() => useReviewStream());
const seedData = {
conflicts: [
{
type: "时间线倒错",
where: "第2段",
refs: [],
suggestion: "对齐时间",
original: null,
replacement: null,
},
],
foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
pace: { water: [], hook: false, beat_map: [1] },
style: { score: 0.9, segments: [] },
characterization: {
issues: [
{
character: "沈砚",
aspect: "动机一致性",
where: "第3段",
quote: "他忽然转身。",
diagnosis: "动机断裂",
suggestion: "扣回护妹动机",
confidence: 0.8,
},
],
},
};
act(() => result.current.seed(seedData));
expect(result.current.state.conflicts).toEqual(seedData.conflicts);
expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
expect(result.current.state.pace).toEqual(seedData.pace);
expect(result.current.state.style).toEqual(seedData.style);
expect(result.current.state.characterization).toEqual(
seedData.characterization,
);
expect(result.current.state.phase).toBe("idle");
});
});