Files
writer-work-flow/apps/web/lib/workbench/useAiConversation.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

189 lines
6.6 KiB
TypeScript
Raw Permalink 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.

// @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 态 + toastreload 可重试", 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、不 toastappend 只 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([]);
});
});