Files
writer-work-flow/apps/web/lib/review/sse.test.ts
Yaojia Wang 29265fa0fd feat(web): 审稿改稿闭环——一键采纳/AI改写/正文高亮定位编辑/伏笔确认/草稿自动保存/章节目录
- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改
- 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁
- 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏
- 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点)
- 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢
- 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:28:29 +02:00

268 lines
7.3 KiB
TypeScript
Raw 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.

import { describe, expect, it } from "vitest";
import {
ReviewFrameBuffer,
initialReviewState,
parseReviewBlock,
reduceReview,
type ReviewSseEvent,
} from "./sse";
describe("parseReviewBlock", () => {
it("parses a section frame", () => {
const evt = parseReviewBlock(
'event:section\ndata:{"name":"continuity","status":"done"}',
);
expect(evt).toEqual({
event: "section",
data: { name: "continuity", status: "done" },
});
});
it("parses a conflict frame", () => {
const evt = parseReviewBlock(
'event:conflict\ndata:{"type":"设定违例","where":"第4段","refs":["第30章"],"suggestion":"统一血脉设定"}',
);
expect(evt).toEqual({
event: "conflict",
data: {
type: "设定违例",
where: "第4段",
refs: ["第30章"],
suggestion: "统一血脉设定",
original: null,
replacement: null,
},
});
});
it("parses done and error frames", () => {
expect(parseReviewBlock('event:done\ndata:{"length":1}')).toEqual({
event: "done",
data: { length: 1 },
});
expect(
parseReviewBlock(
'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
),
).toEqual({
event: "error",
data: { code: "INTERNAL", message: "boom", request_id: "r1" },
});
});
it("parses foreshadow and pace frames (C4 扩 T3.3)", () => {
expect(
parseReviewBlock(
'event:foreshadow\ndata:{"kind":"resolved","code":"F-012","title":"神秘玉佩","where":"第4段","note":"疑似回收"}',
),
).toEqual({
event: "foreshadow",
data: {
kind: "resolved",
code: "F-012",
title: "神秘玉佩",
where: "第4段",
note: "疑似回收",
},
});
expect(
parseReviewBlock(
'event:pace\ndata:{"water":[{"where":"第3段","reason":"注水"}],"hook":false,"beat_map":[1,3,5,2,1,7]}',
),
).toEqual({
event: "pace",
data: {
water: [{ where: "第3段", reason: "注水" }],
hook: false,
beat_map: [1, 3, 5, 2, 1, 7],
},
});
});
it("returns null for unknown event or malformed json", () => {
expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
});
// P1-8逐事件类型守卫——已知事件但 data 形状不符也拒绝(不再强转放行)。
it("returns null when conflict frame lacks required string fields", () => {
expect(
parseReviewBlock('event:conflict\ndata:{"type":"设定违例","refs":[]}'),
).toBeNull();
});
it("returns null when section status is invalid", () => {
expect(
parseReviewBlock('event:section\ndata:{"name":"c","status":"bogus"}'),
).toBeNull();
});
it("drops non-string refs and coerces nullable foreshadow fields", () => {
expect(
parseReviewBlock(
'event:conflict\ndata:{"type":"设定违例","where":"第1段","refs":["第3章",7],"suggestion":"s"}',
),
).toEqual({
event: "conflict",
data: {
type: "设定违例",
where: "第1段",
refs: ["第3章"],
suggestion: "s",
original: null,
replacement: null,
},
});
expect(
parseReviewBlock(
'event:foreshadow\ndata:{"kind":"planted","title":"残图"}',
),
).toEqual({
event: "foreshadow",
data: {
kind: "planted",
code: null,
title: "残图",
where: null,
note: null,
},
});
});
});
describe("ReviewFrameBuffer", () => {
it("emits complete blocks and buffers the tail", () => {
const buf = new ReviewFrameBuffer();
const first = buf.push(
'event:section\ndata:{"name":"continuity","status":"started"}\n\nevent:conf',
);
expect(first).toEqual([
{ event: "section", data: { name: "continuity", status: "started" } },
]);
const second = buf.push(
'lict\ndata:{"type":"性格漂移","where":"第2段","refs":[],"suggestion":"x"}\n\n',
);
expect(second).toEqual([
{
event: "conflict",
data: {
type: "性格漂移",
where: "第2段",
refs: [],
suggestion: "x",
original: null,
replacement: null,
},
},
]);
});
});
describe("reduceReview", () => {
it("upserts sections by name (last status wins) and marks reviewing", () => {
const events: ReviewSseEvent[] = [
{ event: "section", data: { name: "continuity", status: "started" } },
{ event: "section", data: { name: "continuity", status: "done" } },
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.sections).toEqual([
{ name: "continuity", status: "done" },
]);
expect(state.phase).toBe("reviewing");
});
it("accumulates conflicts in order", () => {
const events: ReviewSseEvent[] = [
{
event: "conflict",
data: {
type: "设定违例",
where: "a",
refs: [],
suggestion: "s1",
original: null,
replacement: null,
},
},
{
event: "conflict",
data: {
type: "时间线倒错",
where: "b",
refs: ["第3章"],
suggestion: "s2",
original: null,
replacement: null,
},
},
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.conflicts.map((c) => c.type)).toEqual([
"设定违例",
"时间线倒错",
]);
});
it("accumulates foreshadow suggestions and replaces pace report (last wins)", () => {
const events: ReviewSseEvent[] = [
{
event: "foreshadow",
data: { kind: "planted", title: "残破地图", where: "第5段" },
},
{
event: "foreshadow",
data: { kind: "resolved", code: "F-012", title: "神秘玉佩" },
},
{
event: "pace",
data: { water: [], hook: true, beat_map: [1, 2, 3] },
},
{
event: "pace",
data: {
water: [{ where: "第3段", reason: "注水" }],
hook: false,
beat_map: [3, 1, 5],
},
},
];
const state = events.reduce(reduceReview, initialReviewState);
expect(state.foreshadow.map((f) => f.kind)).toEqual([
"planted",
"resolved",
]);
expect(state.pace?.hook).toBe(false);
expect(state.pace?.beat_map).toEqual([3, 1, 5]);
expect(state.phase).toBe("reviewing");
});
it("marks done and captures error preserving collected data", () => {
let s = reduceReview(initialReviewState, {
event: "section",
data: { name: "continuity", status: "done" },
});
s = reduceReview(s, { event: "done", data: { length: 1 } });
expect(s.phase).toBe("done");
let e = reduceReview(initialReviewState, {
event: "conflict",
data: {
type: "能力不符",
where: "x",
refs: [],
suggestion: "y",
original: null,
replacement: null,
},
});
e = reduceReview(e, {
event: "error",
data: { code: "LLM_UNAVAILABLE", message: "no key" },
});
expect(e.phase).toBe("error");
expect(e.conflicts).toHaveLength(1);
expect(e.error?.code).toBe("LLM_UNAVAILABLE");
});
});