import { describe, expect, it } from "vitest"; import type { CharacterCardView, WorldEntityCardView, } from "@/lib/api/types"; import { buildCharacterGenerateRequest, buildCharacterIngestRequest, buildWorldGenerateRequest, clampCharacterCount, errorCode, extractIngestConflicts, generationErrorMessage, MAX_CHARACTER_COUNT, mergeCharacterCards, mergeWorldEntities, MIN_CHARACTER_COUNT, roleMixTotal, sanitizeRoleMix, updateCard, worldEntityRules, } from "./cards"; const card = (over: Partial = {}): CharacterCardView => ({ name: "甲", role: "主角", appearance: "白衣如雪", motive: "为家族正名", backstory: "出身寒门", arc: "由怯转勇", ...over, }); describe("buildWorldGenerateRequest", () => { it("trims brief", () => { expect(buildWorldGenerateRequest(" 修真世界 ")).toEqual({ brief: "修真世界", }); }); }); describe("clampCharacterCount", () => { it("clamps to [MIN, MAX] and rounds", () => { expect(clampCharacterCount(0)).toBe(MIN_CHARACTER_COUNT); expect(clampCharacterCount(99)).toBe(MAX_CHARACTER_COUNT); expect(clampCharacterCount(3.6)).toBe(4); expect(clampCharacterCount(Number.NaN)).toBe(MIN_CHARACTER_COUNT); }); }); describe("buildCharacterGenerateRequest", () => { it("trims brief, clamps count, omits empty role", () => { expect(buildCharacterGenerateRequest(" 剑修 ", 99)).toEqual({ brief: "剑修", count: MAX_CHARACTER_COUNT, }); }); it("includes trimmed role when present", () => { expect(buildCharacterGenerateRequest("剑修", 2, " 对手 ")).toEqual({ brief: "剑修", count: 2, role: "对手", }); }); it("uses role_mix (count=sum, no single role) when a mix is supplied", () => { expect( buildCharacterGenerateRequest("群像", 1, "忽略", { 主角: 1, 对手: 2, 配角: 3, }), ).toEqual({ brief: "群像", count: 6, role_mix: { 主角: 1, 对手: 2, 配角: 3 }, }); }); it("falls back to count/role when role_mix is empty", () => { expect(buildCharacterGenerateRequest("群像", 4, "主角", {})).toEqual({ brief: "群像", count: 4, role: "主角", }); }); }); describe("sanitizeRoleMix", () => { it("trims roles, drops blanks, clamps counts to >=1, dedups", () => { expect( sanitizeRoleMix([ { role: " 主角 ", count: 2 }, { role: "", count: 3 }, // 空定位丢弃 { role: "对手", count: 0 }, // clamp 到 1 { role: "主角", count: 5 }, // 重复定位丢弃(先到先得) ]), ).toEqual({ 主角: 2, 对手: 1 }); }); it("caps the running total at MAX_CHARACTER_COUNT", () => { const out = sanitizeRoleMix([ { role: "主角", count: 10 }, { role: "对手", count: 5 }, // 只剩 2 额度 { role: "配角", count: 3 }, // 无额度,丢弃 ]); expect(out).toEqual({ 主角: 10, 对手: 2 }); expect(roleMixTotal(out)).toBe(MAX_CHARACTER_COUNT); }); it("returns empty object when no valid rows", () => { expect(sanitizeRoleMix([{ role: " ", count: 3 }])).toEqual({}); }); }); describe("roleMixTotal", () => { it("sums counts", () => { expect(roleMixTotal({ 主角: 1, 对手: 2 })).toBe(3); expect(roleMixTotal({})).toBe(0); }); }); describe("buildCharacterIngestRequest", () => { it("copies cards and carries acknowledge flag", () => { const cards = [card()]; const req = buildCharacterIngestRequest(cards, true); expect(req.acknowledge_conflicts).toBe(true); expect(req.cards).toEqual(cards); expect(req.cards[0]).not.toBe(cards[0]); // immutable copy }); it("defaults acknowledge to false", () => { expect(buildCharacterIngestRequest([card()]).acknowledge_conflicts).toBe( false, ); }); }); describe("updateCard", () => { it("returns a new card with patch applied (no mutation)", () => { const original = card(); const next = updateCard(original, { name: "乙" }); expect(next.name).toBe("乙"); expect(original.name).toBe("甲"); expect(next).not.toBe(original); }); it("patches appearance/motive without dropping the other fields", () => { const original = card(); const next = updateCard(original, { appearance: "玄色劲装", motive: "复仇", }); expect(next.appearance).toBe("玄色劲装"); expect(next.motive).toBe("复仇"); expect(next.backstory).toBe(original.backstory); expect(original.appearance).toBe("白衣如雪"); // no mutation }); }); describe("extractIngestConflicts", () => { it("narrows CONFLICT_UNRESOLVED envelope details", () => { const out = extractIngestConflicts({ error: { code: "CONFLICT_UNRESOLVED", details: { conflicts: [ { type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" }, "junk", ], conflict_count: 2, }, }, }); expect(out).toEqual({ conflictCount: 2, conflicts: [ { type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改", original: null, replacement: null, }, { type: "未分类", where: "", refs: [], suggestion: "", original: null, replacement: null, }, ], }); }); it("returns null for non-conflict errors", () => { expect(extractIngestConflicts({ error: { code: "LLM_UNAVAILABLE" } })).toBeNull(); expect(extractIngestConflicts(null)).toBeNull(); }); it("falls back conflict_count to conflicts length", () => { const out = extractIngestConflicts({ error: { code: "CONFLICT_UNRESOLVED", details: { conflicts: [{ type: "设定违例" }] }, }, }); expect(out?.conflictCount).toBe(1); }); }); describe("errorCode / generationErrorMessage", () => { it("extracts code and maps 503 to settings prompt", () => { expect(errorCode({ error: { code: "LLM_UNAVAILABLE" } })).toBe( "LLM_UNAVAILABLE", ); expect(generationErrorMessage({ error: { code: "LLM_UNAVAILABLE" } })).toContain( "设置", ); expect(generationErrorMessage({ error: { code: "INTERNAL" } })).toContain( "重试", ); }); }); describe("worldEntityRules", () => { it("returns rules or empty array", () => { expect(worldEntityRules({ type: "势力", name: "宗门", rules: ["a"] })).toEqual([ "a", ]); expect(worldEntityRules({ type: "势力", name: "宗门" })).toEqual([]); }); }); describe("mergeCharacterCards", () => { it("appends session cards not already in the persisted list", () => { const persisted = [card({ name: "甲" })]; const session = [card({ name: "乙" })]; const out = mergeCharacterCards(persisted, session); expect(out.map((c) => c.name)).toEqual(["甲", "乙"]); }); it("dedups by name (persisted wins, no duplicate on reload echo)", () => { const persisted = [card({ name: "甲", role: "主角" })]; const session = [card({ name: "甲", role: "对手" })]; const out = mergeCharacterCards(persisted, session); expect(out).toHaveLength(1); expect(out[0]?.role).toBe("主角"); // persisted version kept }); it("returns persisted list when no session cards", () => { const persisted = [card({ name: "甲" })]; expect(mergeCharacterCards(persisted, [])).toEqual(persisted); }); it("preserves relations on the merged cards (Codex relation display)", () => { const persisted = [ card({ name: "甲", relations: [{ name: "乙", kind: "宿敌", note: "灭门之仇" }], }), ]; const out = mergeCharacterCards(persisted, []); expect(out[0]?.relations).toEqual([ { name: "乙", kind: "宿敌", note: "灭门之仇" }, ]); }); }); describe("mergeWorldEntities", () => { const entity = ( over: Partial = {}, ): WorldEntityCardView => ({ type: "势力", name: "宗门", ...over }); it("appends entities not already present (keyed by type+name)", () => { const persisted = [entity({ name: "天剑宗" })]; const session = [entity({ name: "魔渊" })]; expect(mergeWorldEntities(persisted, session).map((e) => e.name)).toEqual([ "天剑宗", "魔渊", ]); }); it("dedups by type+name; same name different type kept separate", () => { const persisted = [entity({ type: "势力", name: "玄" })]; const session = [ entity({ type: "势力", name: "玄" }), entity({ type: "地点", name: "玄" }), ]; const out = mergeWorldEntities(persisted, session); expect(out).toHaveLength(2); expect(out.map((e) => e.type)).toEqual(["势力", "地点"]); }); });