- 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* 码生成名。
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import { useAiConversation } from "./useAiConversation";
|
||
|
||
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
|
||
const get = vi.fn();
|
||
const post = vi.fn();
|
||
const toast = vi.fn();
|
||
vi.mock("@/lib/api/client", () => ({
|
||
api: {
|
||
GET: (...a: unknown[]) => get(...a),
|
||
POST: (...a: unknown[]) => post(...a),
|
||
},
|
||
}));
|
||
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
|
||
|
||
const LIST_PATH = "/projects/{project_id}/ai-messages";
|
||
|
||
function serverRow(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: overrides.id ?? crypto.randomUUID(),
|
||
project_id: "p1",
|
||
chapter_no: 1,
|
||
thread_id: "t1",
|
||
seq: 0,
|
||
kind: "refine",
|
||
tool_key: null,
|
||
role: "ai",
|
||
content: "server",
|
||
meta: {},
|
||
created_at: "2026-07-09T00:00:00Z",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("useAiConversation", () => {
|
||
beforeEach(() => {
|
||
get.mockReset();
|
||
post.mockReset();
|
||
toast.mockReset();
|
||
});
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
it("初始 idle,未触发前不发 GET", () => {
|
||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||
expect(get).not.toHaveBeenCalled();
|
||
expect(result.current.status).toBe("idle");
|
||
expect(result.current.messages).toEqual([]);
|
||
});
|
||
|
||
it("ensureLoaded 懒加载一次,缓存不重复拉取", async () => {
|
||
get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
expect(result.current.messages).toHaveLength(1);
|
||
act(() => result.current.ensureLoaded());
|
||
expect(get).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("GET 传 chapter_no 过滤(本章 ∪ 项目级)", async () => {
|
||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||
const { result } = renderHook(() => useAiConversation("p1", 5));
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
expect(get).toHaveBeenCalledWith(
|
||
LIST_PATH,
|
||
expect.objectContaining({
|
||
params: expect.objectContaining({
|
||
path: { project_id: "p1" },
|
||
query: { chapter_no: 5 },
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("加载失败 → error 态 + toast,reload 可重试", async () => {
|
||
get.mockResolvedValueOnce({ data: null, error: { detail: "boom" } });
|
||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||
|
||
get.mockResolvedValueOnce({ data: { messages: [serverRow()] }, error: null });
|
||
act(() => result.current.reload());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
expect(result.current.messages).toHaveLength(1);
|
||
});
|
||
|
||
it("appendMessages:乐观插入 → 成功后按服务器行正位对账", async () => {
|
||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||
const persisted = serverRow({ id: "srv-1", content: "refined", role: "ai" });
|
||
post.mockResolvedValue({ data: { messages: [persisted] }, error: null });
|
||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
|
||
await act(async () => {
|
||
await result.current.appendMessages({
|
||
threadId: "t1",
|
||
kind: "refine",
|
||
chapterNo: 1,
|
||
messages: [{ role: "ai", content: "refined" }],
|
||
});
|
||
});
|
||
|
||
expect(post).toHaveBeenCalledTimes(1);
|
||
// 临时行被服务器行替换(对账后只剩真实 id)。
|
||
expect(result.current.messages).toHaveLength(1);
|
||
expect(result.current.messages[0]?.id).toBe("srv-1");
|
||
});
|
||
|
||
it("appendMessages 失败 → 回滚乐观行 + toast,绝不 throw", async () => {
|
||
get.mockResolvedValue({ data: { messages: [] }, error: null });
|
||
post.mockResolvedValue({ data: null, error: { detail: "nope" } });
|
||
const { result } = renderHook(() => useAiConversation("p1", 1));
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
|
||
await act(async () => {
|
||
await expect(
|
||
result.current.appendMessages({
|
||
threadId: "t1",
|
||
kind: "refine",
|
||
chapterNo: 1,
|
||
messages: [{ role: "ai", content: "x" }],
|
||
}),
|
||
).resolves.toBeUndefined();
|
||
});
|
||
|
||
expect(result.current.messages).toHaveLength(0);
|
||
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
|
||
});
|
||
|
||
it("silent 模式:不 GET、不 toast,append 只 fire-and-forget POST", async () => {
|
||
post.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||
const { result } = renderHook(() =>
|
||
useAiConversation("p1", 0, { silent: true }),
|
||
);
|
||
act(() => result.current.ensureLoaded());
|
||
expect(get).not.toHaveBeenCalled();
|
||
expect(result.current.status).toBe("idle");
|
||
|
||
await act(async () => {
|
||
await result.current.appendMessages({
|
||
threadId: "t9",
|
||
kind: "generator",
|
||
toolKey: "book-title",
|
||
chapterNo: null,
|
||
messages: [{ role: "author", content: "brief" }],
|
||
});
|
||
});
|
||
expect(post).toHaveBeenCalledTimes(1);
|
||
expect(toast).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("silent 模式 append 失败也不 toast、不 throw", async () => {
|
||
post.mockResolvedValue({ data: null, error: { detail: "x" } });
|
||
const { result } = renderHook(() =>
|
||
useAiConversation("p1", 0, { silent: true }),
|
||
);
|
||
await act(async () => {
|
||
await result.current.appendMessages({
|
||
threadId: "t9",
|
||
kind: "generator",
|
||
chapterNo: null,
|
||
messages: [{ role: "author", content: "brief" }],
|
||
});
|
||
});
|
||
expect(toast).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("切换项目 → 清缓存并复位(重新可加载)", async () => {
|
||
get.mockResolvedValue({ data: { messages: [serverRow()] }, error: null });
|
||
const { result, rerender } = renderHook(
|
||
({ pid }: { pid: string }) => useAiConversation(pid, 1),
|
||
{ initialProps: { pid: "p1" } },
|
||
);
|
||
act(() => result.current.ensureLoaded());
|
||
await waitFor(() => expect(result.current.status).toBe("ready"));
|
||
|
||
rerender({ pid: "p2" });
|
||
expect(result.current.status).toBe("idle");
|
||
expect(result.current.messages).toEqual([]);
|
||
});
|
||
});
|