设定库人物 tab 新增 React Flow 关系图谱,仅用现有角色读端点数据
(CharacterRelationView={name,kind,note}):边按 name-join、kind 作中文
标签、节点按 role 上色,无 closeness/阵营(数据不存在,不硬造)。
- lib/characters/graphLayout.ts:纯逻辑(role→分组配色关键词映射 / 节点
去空重名 / kind→标签+DEFAULT_RELATION_LABEL / 悬空边跳过并计数 / 自指
跳过 / 互指无向去重 / @dagrejs/dagre 一次性确定性初始坐标、中心→左上、
不可变更新)+22 单测。
- components/characters/RelationshipGraph.tsx:'use client',经 CodexPage
dynamic(ssr:false) 挂载;显式容器高度 + import RF CSS + prefers-reduced-
motion 关 fitView 动画 + 纸感 CSS 变量配色 + 悬空计数提示。
- 依赖:@xyflow/react@^12.11.2 + @dagrejs/dagre@^3.0.0。
无后端契约变更/迁移/gen:api。门禁:lint/typecheck 干净、vitest 565、
coverage lib 95.4%(graphLayout 97.6%)、build OK。
259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
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);
|
||
});
|
||
});
|