Files
writer-work-flow/apps/web/lib/toolbox/useGenerator.test.ts
Yaojia Wang 59f2f60a7e refactor(frontend): 四生成 hook 结束后额外返回产物(AC-3 留痕前置)
useRefine.refine/recommunicate→RefineVersion|null、useContinue.generate→string|null、
useChapterRewrite.send→RewriteVersion|null、useGenerator.generate→GeneratorArtifact|null。
向后兼容(既有调用忽略返回值不受影响);各 hook 成功用例补返回值断言。
面板据此 if(artifact) 才 append,空候选/错误路径不留痕。
2026-07-09 17:15:49 +02:00

221 lines
8.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 } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGenerator } from "./useGenerator";
import type { IngestBuildInput } from "./ingest";
// 后端客户端与 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 }));
const conflictEnvelope = {
error: {
code: "CONFLICT_UNRESOLVED",
details: {
conflicts: [{ type: "冲突", where: "world", refs: [], suggestion: "改写" }],
conflict_count: 1,
},
},
};
// 行式可入库产物(金手指 → world_entities
const goldenInput: IngestBuildInput = {
outputKind: "GoldenFingerResult",
rows: [{ name: "吞噬之眼", mechanism: "吞噬", growth: "无限", limits: "反噬" }],
};
describe("useGenerator", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle、无预览、入库 idle、无冲突", () => {
const { result } = renderHook(() => useGenerator());
expect(result.current.genStatus).toBe("idle");
expect(result.current.preview).toBeNull();
expect(result.current.rawPreview).toBeUndefined();
expect(result.current.outputKind).toBeNull();
expect(result.current.ingestStatus).toBe("idle");
expect(result.current.conflicts).toBeNull();
expect(result.current.created).toEqual([]);
});
// —— generate ——
it("生成成功genStatus=preview映射预览模型 + 记录 rawPreview/outputKind", async () => {
const preview = { ideas: [{ premise: "废柴逆袭", hook: "扮猪吃虎" }] };
post.mockResolvedValue({
data: { output_kind: "IdeaListResult", preview },
error: null,
});
const { result } = renderHook(() => useGenerator());
let returned: { outputKind: string; rawPreview: unknown } | null = null;
await act(async () => {
returned = await result.current.generate("p1", "idea", { brief: "脑洞" });
});
expect(result.current.genStatus).toBe("preview");
expect(result.current.outputKind).toBe("IdeaListResult");
expect(result.current.rawPreview).toEqual(preview);
expect(result.current.preview?.kind).toBe("IdeaListResult");
expect(result.current.preview?.items[0]?.heading).toBe("废柴逆袭");
expect(toast).not.toHaveBeenCalled();
// 追加断言generate 返回本次产物(供 GeneratorRunner 结束后留痕)。
expect(returned?.outputKind).toBe("IdeaListResult");
expect(returned?.rawPreview).toEqual(preview);
});
it("生成后端返回 errorgenStatus=error 且弹错误 toast", async () => {
post.mockResolvedValue({ data: null, error: { detail: "失败" } });
const { result } = renderHook(() => useGenerator());
await act(async () => {
await result.current.generate("p1", "idea", { brief: "x" });
});
expect(result.current.genStatus).toBe("error");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("生成请求抛异常genStatus=error 且弹网络异常 toast", async () => {
post.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useGenerator());
await act(async () => {
await result.current.generate("p1", "idea", { brief: "x" });
});
expect(result.current.genStatus).toBe("error");
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
});
// —— ingest ——
it("入库不支持的产物buildIngestRequest 返 null提示并返回 false", async () => {
const { result } = renderHook(() => useGenerator());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", "opening", {
outputKind: "OpeningResult",
rows: [{ text: "正文" }],
});
});
expect(ok).toBe(false);
expect(toast).toHaveBeenCalledWith("该生成器不支持入库。", "error");
expect(post).not.toHaveBeenCalled();
});
it("入库空行:提示至少选择一项并返回 false", async () => {
const { result } = renderHook(() => useGenerator());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", "golden", {
outputKind: "GoldenFingerResult",
rows: [],
});
});
expect(ok).toBe(false);
expect(toast).toHaveBeenCalledWith("请至少选择一项入库。", "error");
expect(post).not.toHaveBeenCalled();
});
it("入库成功ingestStatus=done、记录 created、弹成功 toast返回 true", async () => {
post.mockResolvedValue({
data: { created: ["w1", "w2"], table: "world_entities" },
error: null,
});
const { result } = renderHook(() => useGenerator());
let ok = false;
await act(async () => {
ok = await result.current.ingest("p1", "golden", goldenInput);
});
expect(ok).toBe(true);
expect(result.current.ingestStatus).toBe("done");
expect(result.current.created).toEqual(["w1", "w2"]);
expect(result.current.conflicts).toBeNull();
expect(toast).toHaveBeenCalledWith("已入库 2 项至 world_entities", "success");
});
it("入库成功但 created 缺失:回退空数组并提示 0 项", async () => {
post.mockResolvedValue({ data: { created: null, table: "world_entities" }, error: null });
const { result } = renderHook(() => useGenerator());
await act(async () => {
await result.current.ingest("p1", "golden", goldenInput);
});
expect(result.current.created).toEqual([]);
expect(toast).toHaveBeenCalledWith("已入库 0 项至 world_entities", "success");
});
it("入库成功且有越权写表:额外弹 info toast", async () => {
post.mockResolvedValue({
data: { created: ["w1"], table: "world_entities", rejected_tables: ["secrets"] },
error: null,
});
const { result } = renderHook(() => useGenerator());
await act(async () => {
await result.current.ingest("p1", "golden", goldenInput);
});
expect(toast).toHaveBeenCalledWith("越权写表被丢弃secrets", "info");
});
it("入库 409 冲突ingestStatus=conflict 并暴露 conflicts返回 false", async () => {
post.mockResolvedValue({ data: null, error: conflictEnvelope });
const { result } = renderHook(() => useGenerator());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", "golden", goldenInput);
});
expect(ok).toBe(false);
expect(result.current.ingestStatus).toBe("conflict");
expect(result.current.conflicts?.conflictCount).toBe(1);
expect(result.current.conflicts?.conflicts).toHaveLength(1);
});
it("入库非冲突 erroringestStatus=error 且弹错误 toast返回 false", async () => {
post.mockResolvedValue({ data: null, error: { error: { code: "LLM_UNAVAILABLE" } } });
const { result } = renderHook(() => useGenerator());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", "golden", goldenInput);
});
expect(ok).toBe(false);
expect(result.current.ingestStatus).toBe("error");
expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
});
it("入库请求抛异常ingestStatus=error 且弹网络异常 toast返回 false", async () => {
post.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useGenerator());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", "golden", goldenInput);
});
expect(ok).toBe(false);
expect(result.current.ingestStatus).toBe("error");
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
});
// —— reset ——
it("reset 清回初始态", async () => {
post.mockResolvedValue({
data: { output_kind: "IdeaListResult", preview: { ideas: [{ premise: "p" }] } },
error: null,
});
const { result } = renderHook(() => useGenerator());
await act(async () => {
await result.current.generate("p1", "idea", { brief: "x" });
});
act(() => result.current.reset());
expect(result.current.genStatus).toBe("idle");
expect(result.current.preview).toBeNull();
expect(result.current.rawPreview).toBeUndefined();
expect(result.current.outputKind).toBeNull();
expect(result.current.ingestStatus).toBe("idle");
expect(result.current.conflicts).toBeNull();
expect(result.current.created).toEqual([]);
});
});