Files
writer-work-flow/apps/web/lib/workbench/useContextDrawer.test.ts
Yaojia Wang 3dd7d2861f feat(frontend): 写作工作台补齐三项——内容感知书名 / 上下文速查抽屉 / genre采集
写作工作台重构剩余 3 项(多 agent 并行构建各自新文件,主线统一集成):

- WFW-5 内容感知书名(#6):buildManuscriptExcerpt 取正文首尾摘录(硬上界控 token);
  GeneratorRunner 加 manuscriptText,有 brief 字段时出现「用当前正文」按钮把摘录填入
  brief,让 book-title 等据正文反推。纯前端零后端;HITL——只填表单,生成仍需作者触发。
- WFW-6 上下文速查抽屉(#7):ContextDrawer 视口无关右侧 slide-over,设定库/大纲完整
  只读速查 + 伏笔/规则/文风摘要跳链;底栏「速查」按钮触发。加法式、CONTEXT_DRAWER_ENABLED
  一键回滚、旧侧栏/全宽页路由全保留。
- WFW-7 genre 采集 + 无大纲降级:项目无题材时首次写章前弹 GenrePicker 采集(临时并入
  本次 directive 不落库);chapters 为空时编辑器显式提示「尚无大纲,将按设定自由生成」。

各项 TDD:manuscriptExcerpt/useGenreGate/contextDrawer/useContextDrawer 共 32 新单测。
门禁全绿:vitest 629 / tsc / lint / build / coverage 95.43%。
2026-07-07 20:44:09 +02:00

166 lines
5.5 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.

// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useContextDrawerData } from "./useContextDrawer";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
api: { GET: (...a: unknown[]) => get(...a) },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
const CODEX_PATH = "/projects/{project_id}/world_entities";
const OUTLINE_PATH = "/projects/{project_id}/outline";
// 按路径分流 codex / outline 两个端点的 mock 响应。
function routeGet(
responses: {
codex?: unknown;
outline?: unknown;
codexReject?: boolean;
outlineReject?: boolean;
} = {},
): void {
get.mockImplementation((path: string) => {
if (path === CODEX_PATH) {
if (responses.codexReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.codex ?? { data: null, error: null });
}
if (path === OUTLINE_PATH) {
if (responses.outlineReject) return Promise.reject(new Error("net"));
return Promise.resolve(responses.outline ?? { data: null, error: null });
}
return Promise.resolve({ data: null, error: null });
});
}
describe("useContextDrawerData", () => {
beforeEach(() => {
get.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("抽屉关闭时不发请求,两资源保持 idle", () => {
routeGet();
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", false),
);
expect(get).not.toHaveBeenCalled();
expect(result.current.codex.status).toBe("idle");
expect(result.current.outline.status).toBe("idle");
});
it("打开且在设定库 Tab拉取并归组到 ready", async () => {
routeGet({
codex: {
data: {
world_entities: [
{ type: "势力", name: "雾港议会", rules: [] },
{ type: "地理", name: "北境", rules: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data.map((g) => g.type)).toEqual([
"势力",
"地理",
]);
expect(get).toHaveBeenCalledTimes(1);
expect(toast).not.toHaveBeenCalled();
});
it("打开且在大纲 Tab拉取并按章号升序整形", async () => {
routeGet({
outline: {
data: {
chapters: [
{ no: 2, volume: 1, beats: ["决战"], foreshadow_windows: [] },
{ no: 1, volume: 1, beats: ["开场"], foreshadow_windows: [] },
],
},
error: null,
},
});
const { result } = renderHook(() =>
useContextDrawerData("p1", "outline", true),
);
await waitFor(() => expect(result.current.outline.status).toBe("ready"));
expect(result.current.outline.data.map((r) => r.no)).toEqual([1, 2]);
});
it("data 为空时 ready 且为空数组", async () => {
routeGet({ codex: { data: { world_entities: null }, error: null } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toEqual([]);
});
it("后端返回 errorerror 态 + 错误 toast", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("请求抛异常error 态 + 网络异常 toast", async () => {
routeGet({ codexReject: true });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
expect(toast).toHaveBeenCalledWith(
"设定库加载异常,请检查网络。",
"error",
);
});
it("已加载后切走再切回不重复拉取(缓存)", async () => {
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
const { result, rerender } = renderHook(
({ tab }: { tab: "codex" | "outline" }) =>
useContextDrawerData("p1", tab, true),
{ initialProps: { tab: "codex" } as { tab: "codex" | "outline" } },
);
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
rerender({ tab: "outline" });
rerender({ tab: "codex" });
expect(get.mock.calls.filter((c) => c[0] === CODEX_PATH)).toHaveLength(1);
});
it("reloadCodex 在出错后可重试拉取", async () => {
routeGet({ codex: { data: null, error: { detail: "boom" } } });
const { result } = renderHook(() =>
useContextDrawerData("p1", "codex", true),
);
await waitFor(() => expect(result.current.codex.status).toBe("error"));
routeGet({
codex: {
data: { world_entities: [{ type: "势力", name: "甲", rules: [] }] },
error: null,
},
});
act(() => result.current.reloadCodex());
await waitFor(() => expect(result.current.codex.status).toBe("ready"));
expect(result.current.codex.data).toHaveLength(1);
});
});