Files
writer-work-flow/apps/web/lib/characters/graphLayout.test.ts
Yaojia Wang 3d2a8b8cee feat(web): 角色关系图谱(灵感⑥缩减版/D4)
设定库人物 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。
2026-07-06 17:38:58 +02:00

259 lines
7.5 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.

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=namerole 决定分组/配色", () => {
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);
});
});