Files
writer-work-flow/apps/web/lib/generation/useCharacterGen.test.ts
Yaojia Wang 7babb4854b feat(web): 角色群像按定位配比生成(role_mix 缩减版)
- 契约: CharacterGenerateRequest 加 role_mix{定位:数量},model_validator 归一
  count=各值之和(每项≥1、总和≤12),缺省退回单一 role+count
- @llm: build_character_gen_context/run_character_gen 加 role_mix 参并注入配比块
  (确定性、无时间戳,守 assemble 纯函数);router 穿参
- character-gen.md 加"按配比严格分配 role"纪律 + regen prompt_hashes 金标准
- 前端: sanitizeRoleMix/roleMixTotal + buildCharacterGenerateRequest 第4参 +
  useCharacterGen 穿参 + RoleMixEditor + CharacterGenerator 单一/配比双模
2026-07-06 17:24:16 +02:00

206 lines
8.1 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 type { CharacterCardView } from "@/lib/api/types";
import { useCharacterGen } from "./useCharacterGen";
// 后端客户端与 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: "第3章", refs: ["林风"], suggestion: "沿用旧名" }],
conflict_count: 1,
},
},
};
describe("useCharacterGen", () => {
beforeEach(() => {
post.mockReset();
toast.mockReset();
});
afterEach(() => vi.clearAllMocks());
it("初始为 idle、空卡、入库 idle、无冲突", () => {
const { result } = renderHook(() => useCharacterGen());
expect(result.current.genStatus).toBe("idle");
expect(result.current.cards).toEqual([]);
expect(result.current.ingestStatus).toBe("idle");
expect(result.current.conflicts).toBeNull();
expect(result.current.created).toEqual([]);
});
// —— generate ——
it("生成成功genStatus 走到 preview 并填充卡片", async () => {
const cards = [{ name: "林风", role: "主角" }];
post.mockResolvedValue({ data: { cards }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({ projectId: "p1", brief: "热血少年", count: 3, role: "主角" });
});
expect(result.current.genStatus).toBe("preview");
expect(result.current.cards).toEqual(cards);
expect(toast).not.toHaveBeenCalled();
});
it("按定位配比生成:请求体带 role_mix、count 取各值之和", async () => {
post.mockResolvedValue({ data: { cards: [] }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({
projectId: "p1",
brief: "群像",
count: 1,
role: "忽略",
roleMix: { 主角: 1, 对手: 2 },
});
});
const body = post.mock.calls[0]?.[1]?.body;
expect(body.role_mix).toEqual({ 主角: 1, 对手: 2 });
expect(body.count).toBe(3);
expect(body.role).toBeUndefined();
});
it("生成成功但 cards 缺失:回退为空数组", async () => {
post.mockResolvedValue({ data: { cards: null }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
});
expect(result.current.genStatus).toBe("preview");
expect(result.current.cards).toEqual([]);
});
it("生成后端返回 errorgenStatus=error 且弹错误 toast", async () => {
post.mockResolvedValue({ data: null, error: { detail: "配额不足" } });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
});
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(() => useCharacterGen());
await act(async () => {
await result.current.generate({ projectId: "p1", brief: "b", count: 2 });
});
expect(result.current.genStatus).toBe("error");
expect(toast).toHaveBeenCalledWith("生成请求异常,请检查网络。", "error");
});
// —— ingest ——
it("入库空选择:拒绝并提示,返回 false", async () => {
const { result } = renderHook(() => useCharacterGen());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", []);
});
expect(ok).toBe(false);
expect(toast).toHaveBeenCalledWith("请至少选择一张角色卡。", "error");
expect(post).not.toHaveBeenCalled();
});
it("入库成功ingestStatus=done、记录 created、弹成功 toast返回 true", async () => {
post.mockResolvedValue({ data: { created: ["c1", "c2"] }, error: null });
const { result } = renderHook(() => useCharacterGen());
let ok = false;
await act(async () => {
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
expect(ok).toBe(true);
expect(result.current.ingestStatus).toBe("done");
expect(result.current.created).toEqual(["c1", "c2"]);
expect(result.current.conflicts).toBeNull();
expect(toast).toHaveBeenCalledWith("已入库 2 个角色", "success");
});
it("入库成功但 created 缺失:回退空数组并提示 0 个", async () => {
post.mockResolvedValue({ data: { created: null }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
expect(result.current.created).toEqual([]);
expect(toast).toHaveBeenCalledWith("已入库 0 个角色", "success");
});
it("入库成功且有越权写表:额外弹 info toast", async () => {
post.mockResolvedValue({
data: { created: ["c1"], rejected_tables: ["secrets", "rules"] },
error: null,
});
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
expect(toast).toHaveBeenCalledWith("越权写表被丢弃secrets、rules", "info");
});
it("入库 409 冲突ingestStatus=conflict 并暴露 conflicts返回 false", async () => {
post.mockResolvedValue({ data: null, error: conflictEnvelope });
const { result } = renderHook(() => useCharacterGen());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
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(() => useCharacterGen());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
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(() => useCharacterGen());
let ok = true;
await act(async () => {
ok = await result.current.ingest("p1", [{ name: "林风" } as CharacterCardView]);
});
expect(ok).toBe(false);
expect(result.current.ingestStatus).toBe("error");
expect(toast).toHaveBeenCalledWith("入库请求异常,请检查网络。", "error");
});
// —— reset ——
it("reset 清回初始态", async () => {
post.mockResolvedValue({ data: { cards: [{ name: "林风" } as CharacterCardView] }, error: null });
const { result } = renderHook(() => useCharacterGen());
await act(async () => {
await result.current.generate({ projectId: "p1", brief: "b", count: 1 });
});
act(() => result.current.reset());
expect(result.current.genStatus).toBe("idle");
expect(result.current.cards).toEqual([]);
expect(result.current.ingestStatus).toBe("idle");
expect(result.current.conflicts).toBeNull();
expect(result.current.created).toEqual([]);
});
});