Files
writer-work-flow/apps/web/lib/outline/useOutline.test.ts

127 lines
4.7 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 } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useOutline } from "./useOutline";
import type { OutlineChapterView } from "@/lib/api/types";
// 后端客户端与 Toast 是 hook 的外部副作用边界,单测一律 mock。
const post = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({ api: { POST: (...a: unknown[]) => post(...a) } }));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
function makeChapter(no: number, volume = 1): OutlineChapterView {
return { no, volume };
}
describe("useOutline", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("空初始status=idle", () => {
const { result } = renderHook(() => useOutline([]));
expect(result.current.status).toBe("idle");
expect(result.current.chapters).toEqual([]);
expect(result.current.error).toBeNull();
});
it("非空初始status=ready 并保留章节", () => {
const initial = [makeChapter(1)];
const { result } = renderHook(() => useOutline(initial));
expect(result.current.status).toBe("ready");
expect(result.current.chapters).toEqual(initial);
});
it("生成成功status 走到 ready 并填充章节并弹成功 toast", async () => {
const chapters = [makeChapter(1), makeChapter(2)];
post.mockResolvedValue({ data: { chapters }, error: null });
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("ready");
expect(result.current.chapters).toEqual(chapters);
expect(result.current.error).toBeNull();
expect(toast).toHaveBeenCalledWith("大纲已生成", "success");
});
it("生成只替换目标卷,保留其它卷的已存章节", async () => {
const initial = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(80, 2)];
const regenerated = [makeChapter(1, 1), makeChapter(2, 1), makeChapter(3, 1)];
post.mockResolvedValue({ data: { chapters: regenerated }, error: null });
const { result } = renderHook(() => useOutline(initial));
await act(async () => {
await result.current.generate("p1", 1);
});
// 卷 2 的第 80 章不被整列覆盖;卷 1 替换为新生成的三章。
expect(result.current.chapters).toEqual([
makeChapter(80, 2),
makeChapter(1, 1),
makeChapter(2, 1),
makeChapter(3, 1),
]);
});
it("data.chapters 为空时章节回退为空数组", async () => {
post.mockResolvedValue({ data: { chapters: null }, error: null });
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("ready");
expect(result.current.chapters).toEqual([]);
});
it("后端返回业务错误:透传 friendly 文案 + 跳转动作(不再固定 OUTLINE_FAILED", async () => {
post.mockResolvedValue({
data: null,
error: { error: { code: "LLM_UNAVAILABLE" } },
});
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
// LLM_UNAVAILABLE → 透传 provider 文案 + 「去设置提供商」链接。
expect(result.current.error?.text).toContain("provider");
expect(result.current.error?.actionHref).toBe("/settings/providers");
expect(result.current.error?.actionLabel).toBe("去设置提供商");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("校验错误422 detail 信封):透传 VALIDATION 文案且无跳转动作", async () => {
post.mockResolvedValue({
data: null,
error: { detail: [{ msg: "字段缺失" }] },
});
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(result.current.error?.text).toBeTruthy();
expect(result.current.error?.actionHref).toBeUndefined();
expect(result.current.error?.actionLabel).toBeUndefined();
});
it("data 为空且无 error 也走错误分支(兜底文案、无跳转)", async () => {
post.mockResolvedValue({ data: null, error: null });
const { result } = renderHook(() => useOutline([]));
await act(async () => {
await result.current.generate("p1", 1);
});
expect(result.current.status).toBe("error");
expect(result.current.error?.text).toBeTruthy();
expect(result.current.error?.actionHref).toBeUndefined();
});
});