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 { 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/seq,created_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(""); }); });