Files
writer-work-flow/apps/web/lib/workbench/inlineConversation.test.ts
Yaojia Wang 5af45f5034 refactor(frontend): 抽出可复用 ConversationThread + inlineConversation 纯助手
把 AiConversationDrawer 内联的气泡渲染(Bubble/readOptions/ACCEPT_LABEL)提取为
共享的 ConversationThread 组件,抽屉改为复用之(外观不变,DRY)。新增纯助手
lib/workbench/inlineConversation.ts:mergeInlineThreads(已落库留痕 ∪ 进行中气泡,
按 id 去重 + 复用 groupByThread 定序)、toPendingView(合成未落库气泡,meta._pending/
_streaming UI 标记)、isPendingView/isStreamingView/readSegment,配 9 单测(RED→GREEN)。
为内联对话视图铺路;抽屉行为完全不变。
2026-07-10 16:31:53 +02:00

123 lines
4.7 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.

import { describe, expect, it } from "vitest";
import type { AiMessageView } from "@/lib/api/types";
import {
PENDING_CREATED_AT,
isPendingView,
isStreamingView,
mergeInlineThreads,
readSegment,
toPendingView,
} from "./inlineConversation";
// AiMessageView 工厂:只填测试关心的字段,其余给稳定默认(对齐 aiConversation.test.ts
function view(partial: Partial<AiMessageView>): AiMessageView {
return {
id: partial.id ?? crypto.randomUUID(),
project_id: "p1",
chapter_no: partial.chapter_no ?? null,
thread_id: partial.thread_id ?? "t1",
seq: partial.seq ?? 0,
kind: partial.kind ?? "refine",
tool_key: partial.tool_key ?? null,
role: partial.role ?? "ai",
content: partial.content ?? "",
meta: partial.meta ?? {},
created_at: partial.created_at ?? "2026-07-09T00:00:00Z",
};
}
describe("mergeInlineThreads", () => {
it("只保留本章 + 指定 kinds 的已落库消息", () => {
const persisted = [
view({ thread_id: "a", chapter_no: 1, kind: "refine", seq: 0 }),
view({ thread_id: "b", chapter_no: 2, kind: "refine", seq: 0 }), // 他章
view({ thread_id: "c", chapter_no: 1, kind: "continue", seq: 0 }), // 他 kind
];
const threads = mergeInlineThreads(persisted, 1, ["refine", "clarify"], []);
expect(threads.map((t) => t.threadId)).toEqual(["a"]);
});
it("并入进行中气泡(按 id 去重、已落库优先),排在本章会话末尾", () => {
const persisted = [
view({ id: "p1", thread_id: "s", chapter_no: 1, kind: "refine", seq: 0, role: "author", content: "原文" }),
view({ id: "p2", thread_id: "s", chapter_no: 1, kind: "refine", seq: 1, role: "ai", content: "第一版" }),
];
const pending = [
toPendingView({ localId: "pend-a", role: "author", content: "再改" }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 0 }),
toPendingView({ localId: "pend-b", role: "ai", content: "", streaming: true }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 1 }),
];
const threads = mergeInlineThreads(persisted, 1, ["refine"], pending);
expect(threads).toHaveLength(1);
expect(threads[0]!.messages.map((m) => m.id)).toEqual(["p1", "p2", "pend-a", "pend-b"]);
});
it("进行中气泡 id 与已落库重复时不重复(已落库优先)", () => {
const persisted = [view({ id: "dup", thread_id: "s", chapter_no: 1, kind: "refine", seq: 0 })];
const pending = [
toPendingView({ localId: "dup", role: "ai", content: "x" }, { threadId: "s", chapterNo: 1, kind: "refine", seq: 9 }),
];
const threads = mergeInlineThreads(persisted, 1, ["refine"], pending);
expect(threads[0]!.messages).toHaveLength(1);
expect(threads[0]!.messages[0]!.id).toBe("dup");
});
it("无已落库时进行中气泡自成一线程", () => {
const pending = [
toPendingView({ localId: "x", role: "author", content: "hi" }, { threadId: "new", chapterNo: 1, kind: "rewrite", seq: 0 }),
];
const threads = mergeInlineThreads([], 1, ["rewrite"], pending);
expect(threads).toHaveLength(1);
expect(threads[0]!.messages[0]!.id).toBe("x");
});
it("空输入 → 空数组", () => {
expect(mergeInlineThreads([], 1, ["refine"], [])).toEqual([]);
});
});
describe("toPendingView", () => {
it("合成未落库气泡meta._pending映射 role/content/kind/seqcreated_at 恒为未来常量", () => {
const v = toPendingView(
{ localId: "L1", role: "ai", content: "改写中", streaming: true },
{ threadId: "t9", chapterNo: 3, kind: "rewrite", seq: 2 },
);
expect(v.id).toBe("L1");
expect(v.role).toBe("ai");
expect(v.content).toBe("改写中");
expect(v.thread_id).toBe("t9");
expect(v.chapter_no).toBe(3);
expect(v.kind).toBe("rewrite");
expect(v.seq).toBe(2);
expect(v.created_at).toBe(PENDING_CREATED_AT);
expect(isPendingView(v)).toBe(true);
expect(isStreamingView(v)).toBe(true);
});
it("非流式种子不带 _streaming", () => {
const v = toPendingView(
{ localId: "L2", role: "author", content: "x" },
{ threadId: "t", chapterNo: 1, kind: "refine", seq: 0 },
);
expect(isStreamingView(v)).toBe(false);
expect(isPendingView(v)).toBe(true);
});
});
describe("isPendingView / isStreamingView", () => {
it("已落库消息(无标记)均为 false", () => {
const v = view({});
expect(isPendingView(v)).toBe(false);
expect(isStreamingView(v)).toBe(false);
});
});
describe("readSegment", () => {
it("取 meta.segment 字符串;缺失/非串回退空串", () => {
expect(readSegment({ segment: "原选段" })).toBe("原选段");
expect(readSegment({})).toBe("");
expect(readSegment({ segment: 42 })).toBe("");
expect(readSegment(undefined)).toBe("");
});
});