- lib/workbench/aiConversation.ts:纯视图模型(kind 标签/徽标、bubbleSide、 按 thread 归组 (created_at,seq) 定序、按作用域分区、接受动作映射)+ 单测。 - lib/workbench/useAiConversation.ts:Workbench 级懒加载 + 批量 append(乐观插入 + 正位对账 / 失败回滚),fail-soft 绝不 throw 进生成/HITL;silent 直投供工具箱页。 renderHook 测试覆盖懒加载去重、对账、回滚、silent、项目切换复位。 - components/workbench/AiConversationDrawer.tsx:本章对话 + 项目级生成两段的气泡抽屉, 克隆 ContextDrawer 的 a11y(role=dialog/log、Esc、focus trap、还原焦点); ai 气泡挂 DRAFT-only 再接受按钮(替换整章/插入正文/回填选段),不绕开验收事务。 - lib/api/types.ts:re-export AiMessage* 码生成名。
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
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>): 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();
|
||
});
|
||
});
|