Files
writer-work-flow/apps/web/lib/workbench/aiConversation.test.ts
Yaojia Wang ddf7fa061a feat(frontend): AI 对话核心——视图模型 + 数据/追加 hook + 抽屉(AC-3)
- 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* 码生成名。
2026-07-09 17:11:09 +02:00

136 lines
5.1 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 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();
});
});