buildGenerateRequest 转发 text(扩写/降AI/拆书原文,text_input 策略;空白省略)。
mapPreview 处理单对象产物:续写/扩写/降AI({text}→单 body 卡)、拆书
BookTeardownResult(structure 作标题 + themes/archetypes/hooks 列表字段)——
绕过 firstArrayEntry 的「记录列表」假设,使 4 新生成器声明驱动表单/预览自动可渲染。
原文 textarea 字段(name=text)由既有 FormField 渲染,无需改组件。
213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import type { ToolDescriptorView, ToolInputFieldView } from "@/lib/api/types";
|
|
import {
|
|
buildGenerateRequest,
|
|
initialFieldValues,
|
|
isLegacyTool,
|
|
mapPreview,
|
|
missingRequiredFields,
|
|
previewRows,
|
|
resolveLegacyRoute,
|
|
} from "./toolbox";
|
|
|
|
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
|
|
name: "brief",
|
|
label: "需求",
|
|
type: "textarea",
|
|
required: true,
|
|
...over,
|
|
});
|
|
|
|
describe("initialFieldValues", () => {
|
|
it("seeds defaults and empty strings", () => {
|
|
const values = initialFieldValues([
|
|
field({ name: "brief", default: undefined }),
|
|
field({ name: "count", type: "number", default: "3", required: false }),
|
|
]);
|
|
expect(values).toEqual({ brief: "", count: "3" });
|
|
});
|
|
|
|
it("handles undefined fields", () => {
|
|
expect(initialFieldValues(undefined)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe("missingRequiredFields", () => {
|
|
it("reports labels of blank required fields only", () => {
|
|
const fields = [
|
|
field({ name: "brief", label: "需求", required: true }),
|
|
field({ name: "count", label: "数量", required: false }),
|
|
];
|
|
expect(missingRequiredFields(fields, { brief: " ", count: "" })).toEqual([
|
|
"需求",
|
|
]);
|
|
});
|
|
|
|
it("passes when all required present", () => {
|
|
expect(
|
|
missingRequiredFields([field()], { brief: "一个脑洞" }),
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("buildGenerateRequest", () => {
|
|
it("trims brief and maps known number fields", () => {
|
|
expect(
|
|
buildGenerateRequest({ brief: " 修真 ", count: "5", chapter_no: "3" }),
|
|
).toEqual({ brief: "修真", count: 5, chapter_no: 3 });
|
|
});
|
|
|
|
it("omits blank/invalid numbers and empty kind", () => {
|
|
expect(
|
|
buildGenerateRequest({ brief: "x", count: "", chapter_no: "abc", kind: " " }),
|
|
).toEqual({ brief: "x" });
|
|
});
|
|
|
|
it("includes kind when present", () => {
|
|
expect(buildGenerateRequest({ brief: "x", kind: "门派" })).toEqual({
|
|
brief: "x",
|
|
kind: "门派",
|
|
});
|
|
});
|
|
|
|
it("defaults brief to empty string", () => {
|
|
expect(buildGenerateRequest({})).toEqual({ brief: "" });
|
|
});
|
|
|
|
it("forwards trimmed text (扩写/降AI/拆书 原文输入)", () => {
|
|
expect(
|
|
buildGenerateRequest({ brief: "扩写", text: " 原始正文片段 " }),
|
|
).toEqual({ brief: "扩写", text: "原始正文片段" });
|
|
});
|
|
|
|
it("omits blank/whitespace-only text", () => {
|
|
expect(buildGenerateRequest({ brief: "x", text: " " })).toEqual({
|
|
brief: "x",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("previewRows", () => {
|
|
it("picks the first array field as records", () => {
|
|
const rows = previewRows({ ideas: [{ premise: "a" }, { premise: "b" }] });
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0]).toEqual({ premise: "a" });
|
|
});
|
|
|
|
it("returns empty for missing/non-array", () => {
|
|
expect(previewRows(undefined)).toEqual([]);
|
|
expect(previewRows({ note: "x" })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("mapPreview", () => {
|
|
it("maps IdeaListResult to heading + fields", () => {
|
|
const model = mapPreview("IdeaListResult", {
|
|
ideas: [{ premise: "废柴逆袭", hook: "系统觉醒", genre_fit: "玄幻" }],
|
|
});
|
|
expect(model.kind).toBe("IdeaListResult");
|
|
expect(model.items[0]?.heading).toBe("废柴逆袭");
|
|
expect(model.items[0]?.fields).toEqual([
|
|
{ label: "爽点", value: "系统觉醒" },
|
|
{ label: "题材契合", value: "玄幻" },
|
|
]);
|
|
});
|
|
|
|
it("maps BlurbResult text into body and angle into heading", () => {
|
|
const model = mapPreview("BlurbResult", {
|
|
variants: [{ angle: "悬念向", text: "一段简介" }],
|
|
});
|
|
expect(model.items[0]?.heading).toBe("悬念向");
|
|
expect(model.items[0]?.body).toBe("一段简介");
|
|
});
|
|
|
|
it("maps GlossaryResult rules into a joined field", () => {
|
|
const model = mapPreview("GlossaryResult", {
|
|
terms: [{ name: "灵气", type: "概念", rules: ["不可逆", "有上限"] }],
|
|
});
|
|
const ruleField = model.items[0]?.fields.find((f) => f.label === "硬规则");
|
|
expect(ruleField?.value).toBe("不可逆;有上限");
|
|
});
|
|
|
|
it("maps OpeningResult as pure body", () => {
|
|
const model = mapPreview("OpeningResult", {
|
|
variants: [{ text: "开篇第一段" }],
|
|
});
|
|
expect(model.items[0]?.heading).toBeNull();
|
|
expect(model.items[0]?.body).toBe("开篇第一段");
|
|
});
|
|
|
|
it("falls back gracefully for unknown output_kind", () => {
|
|
const model = mapPreview("MysteryResult", {
|
|
things: [{ a: "first", b: "second" }],
|
|
});
|
|
expect(model.items[0]?.heading).toBe("first");
|
|
expect(model.items[0]?.fields).toEqual([{ label: "b", value: "second" }]);
|
|
});
|
|
|
|
it("yields no items for empty preview", () => {
|
|
expect(mapPreview("IdeaListResult", undefined).items).toEqual([]);
|
|
});
|
|
|
|
it("maps a text-only result (续写/扩写/降AI) into a single body item", () => {
|
|
for (const kind of ["ContinuationResult", "PolishResult", "DeAiResult"]) {
|
|
const model = mapPreview(kind, { text: "一段续写/改写后的正文" });
|
|
expect(model.items).toHaveLength(1);
|
|
expect(model.items[0]?.heading).toBeNull();
|
|
expect(model.items[0]?.body).toBe("一段续写/改写后的正文");
|
|
}
|
|
});
|
|
|
|
it("maps BookTeardownResult structured fields into one card", () => {
|
|
const model = mapPreview("BookTeardownResult", {
|
|
themes: ["逆袭", "复仇"],
|
|
archetypes: ["废柴主角"],
|
|
structure: "三幕递进",
|
|
hooks: ["开局打脸", "扮猪吃虎"],
|
|
});
|
|
expect(model.items).toHaveLength(1);
|
|
const item = model.items[0];
|
|
expect(item?.heading).toBe("三幕递进");
|
|
const byLabel = (label: string) =>
|
|
item?.fields.find((f) => f.label === label)?.value;
|
|
expect(byLabel("主题")).toBe("逆袭;复仇");
|
|
expect(byLabel("人物原型")).toBe("废柴主角");
|
|
expect(byLabel("钩子套路")).toBe("开局打脸;扮猪吃虎");
|
|
});
|
|
|
|
it("yields no items for an empty text result", () => {
|
|
expect(mapPreview("ContinuationResult", { text: "" }).items).toEqual([]);
|
|
expect(mapPreview("ContinuationResult", undefined).items).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("resolveLegacyRoute", () => {
|
|
it("substitutes the {id} placeholder", () => {
|
|
expect(resolveLegacyRoute("/projects/{id}/codex?gen=world", "p9")).toBe(
|
|
"/projects/p9/codex?gen=world",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("isLegacyTool", () => {
|
|
const tool = (over: Partial<ToolDescriptorView>): ToolDescriptorView => ({
|
|
key: "k",
|
|
title: "t",
|
|
subtitle: "s",
|
|
is_legacy: false,
|
|
ingestable: false,
|
|
...over,
|
|
});
|
|
|
|
it("is true only with legacy flag and a route", () => {
|
|
expect(
|
|
isLegacyTool(tool({ is_legacy: true, legacy_route: "/x/{id}" })),
|
|
).toBe(true);
|
|
expect(isLegacyTool(tool({ is_legacy: true }))).toBe(false);
|
|
expect(
|
|
isLegacyTool(tool({ is_legacy: false, legacy_route: "/x/{id}" })),
|
|
).toBe(false);
|
|
});
|
|
});
|