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%。
This commit is contained in:
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal file
165
apps/web/lib/workbench/useContextDrawer.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// @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("后端返回 error:error 态 + 错误 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user