import { describe, expect, it } from "vitest"; import type { AiMessageView } from "@/lib/api/types"; import { acceptActionFor, bubbleSide, groupByThread, KIND_LABEL, kindLabel, partitionByScope, } from "./aiConversation"; // AiMessageView 工厂:只填测试关心的字段,其余给稳定默认。 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("kindLabel / KIND_LABEL", () => { it("五类 kind 各有中文标签", () => { expect(KIND_LABEL.refine).toBe("润色"); expect(KIND_LABEL.rewrite).toBe("整章重写"); expect(KIND_LABEL.clarify).toBe("反问澄清"); expect(KIND_LABEL.continue).toBe("续写"); expect(KIND_LABEL.generator).toBe("工具箱"); }); it("未知 kind 回退为原始串(不崩)", () => { expect(kindLabel("mystery")).toBe("mystery"); expect(kindLabel("refine")).toBe("润色"); }); }); describe("bubbleSide", () => { it("author 靠右、ai 靠左", () => { expect(bubbleSide("author")).toBe("right"); expect(bubbleSide("ai")).toBe("left"); }); }); describe("groupByThread", () => { it("按 thread_id 归组,返回每组 kind/toolKey/chapterNo", () => { const msgs = [ view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 0 }), view({ thread_id: "b", kind: "continue", tool_key: "continue", chapter_no: 1, seq: 0 }), view({ thread_id: "a", kind: "refine", chapter_no: 1, seq: 1 }), ]; const threads = groupByThread(msgs); const a = threads.find((t) => t.threadId === "a"); expect(a?.messages).toHaveLength(2); expect(a?.kind).toBe("refine"); const b = threads.find((t) => t.threadId === "b"); expect(b?.toolKey).toBe("continue"); expect(b?.chapterNo).toBe(1); }); it("同一批次 created_at 相同时,按 seq 升序排组内(不靠 created_at 单独定序)", () => { // 故意乱序传入;同 created_at,靠 seq 稳定定序。 const msgs = [ view({ thread_id: "a", seq: 2, content: "c2", created_at: "2026-07-09T00:00:00Z" }), view({ thread_id: "a", seq: 0, content: "c0", created_at: "2026-07-09T00:00:00Z" }), view({ thread_id: "a", seq: 1, content: "c1", created_at: "2026-07-09T00:00:00Z" }), ]; const thread = groupByThread(msgs)[0]!; expect(thread.messages.map((m) => m.content)).toEqual(["c0", "c1", "c2"]); }); it("跨批次靠 created_at 定序,组内新批 append 在后", () => { const msgs = [ view({ thread_id: "a", seq: 0, content: "old", created_at: "2026-07-09T00:00:00Z" }), view({ thread_id: "a", seq: 0, content: "new", created_at: "2026-07-09T00:05:00Z" }), ]; const thread = groupByThread(msgs)[0]!; expect(thread.messages.map((m) => m.content)).toEqual(["old", "new"]); }); it("线程按各自首条 (created_at, seq) 升序(老线程在前)", () => { const msgs = [ view({ thread_id: "late", seq: 0, created_at: "2026-07-09T01:00:00Z" }), view({ thread_id: "early", seq: 0, created_at: "2026-07-09T00:00:00Z" }), ]; const threads = groupByThread(msgs); expect(threads.map((t) => t.threadId)).toEqual(["early", "late"]); }); it("空输入 → 空数组", () => { expect(groupByThread([])).toEqual([]); }); }); describe("partitionByScope", () => { it("本章线程与项目级(chapter_no=null)线程分区", () => { const msgs = [ view({ thread_id: "chap", chapter_no: 3, seq: 0 }), view({ thread_id: "proj", chapter_no: null, kind: "generator", seq: 0 }), ]; const threads = groupByThread(msgs); const { chapterThreads, projectThreads } = partitionByScope(threads, 3); expect(chapterThreads.map((t) => t.threadId)).toEqual(["chap"]); expect(projectThreads.map((t) => t.threadId)).toEqual(["proj"]); }); it("非本章号的章线程不落入本章分区(联合查询兜底)", () => { const threads = groupByThread([ view({ thread_id: "other", chapter_no: 9, seq: 0 }), ]); const { chapterThreads, projectThreads } = partitionByScope(threads, 3); expect(chapterThreads).toHaveLength(0); expect(projectThreads).toHaveLength(0); }); }); describe("acceptActionFor", () => { it("按 kind 映射接受动作;clarify/未知无动作", () => { const mk = (kind: string) => groupByThread([view({ thread_id: kind, kind, seq: 0 })])[0]!; expect(acceptActionFor(mk("rewrite"))).toBe("replace_chapter"); expect(acceptActionFor(mk("continue"))).toBe("append_draft"); expect(acceptActionFor(mk("generator"))).toBe("append_draft"); expect(acceptActionFor(mk("refine"))).toBe("refill_segment"); expect(acceptActionFor(mk("clarify"))).toBeNull(); expect(acceptActionFor(mk("mystery"))).toBeNull(); }); });