import { describe, expect, it } from "vitest"; import type { CharacterCardView } from "@/lib/api/types"; import { buildGraphEdges, buildGraphNodes, buildRelationshipGraph, countDanglingRelations, DEFAULT_RELATION_LABEL, layoutGraph, roleStyle, } from "./graphLayout"; // 最小角色卡工厂:CharacterCardView 多字段必填,测试只关心 name/role/relations。 function ch( name: string, role: string, relations: { name: string; kind?: string; note?: string | null }[] = [], ): CharacterCardView { return { name, role, traits: [], appearance: "", motive: "", backstory: "", arc: "", speech_tics: [], relations: relations.map((r) => ({ name: r.name, kind: r.kind ?? "", note: r.note ?? null, })), }; } describe("roleStyle", () => { it("主角关键词 → protagonist + 朱砂色", () => { expect(roleStyle("主角")).toEqual({ group: "protagonist", colorVar: "--color-cinnabar", }); expect(roleStyle("男主角").group).toBe("protagonist"); }); it("反派/对手/敌 → antagonist + 冲突色", () => { expect(roleStyle("反派").group).toBe("antagonist"); expect(roleStyle("对手").group).toBe("antagonist"); expect(roleStyle("宿敌").group).toBe("antagonist"); expect(roleStyle("反派").colorVar).toBe("--color-conflict"); }); it("导师/师父 → mentor + info 色", () => { expect(roleStyle("导师").group).toBe("mentor"); expect(roleStyle("师父").group).toBe("mentor"); expect(roleStyle("导师").colorVar).toBe("--color-info"); }); it("CP/恋人 → ally(大小写不敏感)", () => { expect(roleStyle("CP").group).toBe("ally"); expect(roleStyle("cp").group).toBe("ally"); expect(roleStyle("恋人").group).toBe("ally"); }); it("配角/工具人 → supporting", () => { expect(roleStyle("配角").group).toBe("supporting"); expect(roleStyle("工具人").group).toBe("supporting"); }); it("未知/空定位 → other(默认)", () => { expect(roleStyle("神秘存在").group).toBe("other"); expect(roleStyle("").group).toBe("other"); expect(roleStyle(" ").group).toBe("other"); }); }); describe("buildGraphNodes", () => { it("每个唯一角色一个节点,id=name,role 决定分组/配色", () => { const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]); expect(nodes).toHaveLength(2); expect(nodes[0]).toMatchObject({ id: "林风", position: { x: 0, y: 0 }, data: { label: "林风", role: "主角", group: "protagonist", colorVar: "--color-cinnabar", }, }); expect(nodes[1]?.data.group).toBe("antagonist"); }); it("跳过空名与重名角色(先到先得)", () => { const nodes = buildGraphNodes([ ch("林风", "主角"), ch(" ", "配角"), ch("林风", "对手"), ]); expect(nodes).toHaveLength(1); expect(nodes[0]?.data.role).toBe("主角"); }); it("名字两端空白被裁剪用作 id", () => { const nodes = buildGraphNodes([ch(" 林风 ", "主角")]); expect(nodes[0]?.id).toBe("林风"); }); }); describe("buildGraphEdges", () => { const known = new Set(["林风", "赵石", "青禾"]); it("kind → 边标签", () => { const edges = buildGraphEdges( [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], known, ); expect(edges).toHaveLength(1); expect(edges[0]).toMatchObject({ source: "林风", target: "赵石", label: "宿敌", }); }); it("kind 为空 → 回退到命名常量默认标签", () => { const edges = buildGraphEdges( [ch("林风", "主角", [{ name: "青禾", kind: "" }])], known, ); expect(edges[0]?.label).toBe(DEFAULT_RELATION_LABEL); }); it("悬空边(目标未入库)被跳过", () => { const edges = buildGraphEdges( [ch("林风", "主角", [{ name: "不存在的人", kind: "师父" }])], known, ); expect(edges).toHaveLength(0); }); it("自指关系被跳过", () => { const edges = buildGraphEdges( [ch("林风", "主角", [{ name: "林风", kind: "内心" }])], known, ); expect(edges).toHaveLength(0); }); it("互指关系无向去重为单边", () => { const edges = buildGraphEdges( [ ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }]), ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]), ], known, ); expect(edges).toHaveLength(1); }); it("边 id 唯一", () => { const edges = buildGraphEdges( [ ch("林风", "主角", [ { name: "赵石", kind: "宿敌" }, { name: "青禾", kind: "青梅竹马" }, ]), ], known, ); const ids = edges.map((e) => e.id); expect(new Set(ids).size).toBe(ids.length); }); }); describe("countDanglingRelations", () => { it("统计引用未入库角色的关系数", () => { const known = new Set(["林风"]); const count = countDanglingRelations( [ ch("林风", "主角", [ { name: "赵石", kind: "宿敌" }, // 悬空 { name: "青禾", kind: "青梅竹马" }, // 悬空 ]), ], known, ); expect(count).toBe(2); }); it("全部已入库时为 0", () => { const known = new Set(["林风", "赵石"]); const count = countDanglingRelations( [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], known, ); expect(count).toBe(0); }); }); describe("layoutGraph", () => { const nodes = buildGraphNodes([ch("林风", "主角"), ch("赵石", "对手")]); const edges = buildGraphEdges( [ch("林风", "主角", [{ name: "赵石", kind: "宿敌" }])], new Set(["林风", "赵石"]), ); it("同输入产出相同坐标(确定性)", () => { const a = layoutGraph(nodes, edges); const b = layoutGraph(nodes, edges); expect(a).toEqual(b); }); it("dagre 赋予非零坐标并保持节点数据", () => { const laid = layoutGraph(nodes, edges); expect(laid).toHaveLength(2); for (const n of laid) { expect(Number.isFinite(n.position.x)).toBe(true); expect(Number.isFinite(n.position.y)).toBe(true); expect(n.data.label).toBeTruthy(); } // 两节点经布局后不再重叠于原点。 expect(laid[0]?.position).not.toEqual(laid[1]?.position); }); it("不可变:不修改传入节点,返回新对象", () => { const before = structuredClone(nodes); const laid = layoutGraph(nodes, edges); expect(nodes).toEqual(before); // 入参未被修改 expect(laid[0]).not.toBe(nodes[0]); // 新对象 }); }); describe("buildRelationshipGraph", () => { it("端到端:布局后节点 + 有效边 + 悬空计数", () => { const graph = buildRelationshipGraph([ ch("林风", "主角", [ { name: "赵石", kind: "宿敌" }, { name: "幽灵", kind: "谜团" }, // 悬空 ]), ch("赵石", "对手", [{ name: "林风", kind: "宿敌" }]), ]); expect(graph.nodes).toHaveLength(2); expect(graph.edges).toHaveLength(1); expect(graph.danglingCount).toBe(1); // 已布局(非全零)。 const positions = graph.nodes.map((n) => `${n.position.x},${n.position.y}`); expect(new Set(positions).size).toBe(2); }); it("空输入 → 空图", () => { const graph = buildRelationshipGraph([]); expect(graph.nodes).toEqual([]); expect(graph.edges).toEqual([]); expect(graph.danglingCount).toBe(0); }); });