feat(textbooks): 知识图谱功能全面重构 — 前置依赖 + dagre 布局 + React Flow 可视化 + 师生双视角
将教材模块图谱从基本无用状态升级为完整知识图谱可视化系统。 数据层:新增 knowledgePointPrerequisites 表(复合主键+双外键 cascade);新增 data-access-graph.ts(server-only)知识点关联聚合、学生/班级掌握度查询;utils.ts 新增 hasCycleAfterAddingEdge(DFS 循环依赖检测)。 业务层:3 个新 Server Action(getKnowledgeGraphDataAction 三视图模式、createPrerequisiteAction 含循环检测、deletePrerequisiteAction);graph-layout.ts 重写为 dagre 分层有向图布局。 视图层:knowledge-graph.tsx 重写为 React Flow 主组件(全书视图+搜索高亮+关联节点高亮+章节着色);4 个新组件(graph-kp-node/graph-prerequisite-edge/graph-toolbar/graph-node-detail-panel);use-graph-data.ts 派生值模式避免 effect 中 setState。 架构:严格三层架构,客户端通过 Server Action 间接访问 server-only 数据层;权限校验+ i18n 全覆盖;架构文档 004/005 同步。 测试:utils.test.ts 新增 5 个循环检测测试,graph-layout.test.ts 重写 5 个 dagre 布局测试,全部 30 个教材模块单元测试通过。 附带提交 drizzle/0005 error-book 迁移文件以保持 journal 一致性。
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { KnowledgePoint } from "./types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
|
||||
import type { KpWithRelations } from "./types"
|
||||
import { computeGraphLayout } from "./graph-layout"
|
||||
|
||||
describe("textbooks/graph-layout", () => {
|
||||
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
|
||||
const makeKp = (
|
||||
id: string,
|
||||
parentId: string | null = null,
|
||||
prerequisiteIds: string[] = [],
|
||||
): KpWithRelations => ({
|
||||
id,
|
||||
name: `KP-${id}`,
|
||||
chapterId: "c1",
|
||||
description: null,
|
||||
parentId,
|
||||
chapterId: "c1",
|
||||
level: 1,
|
||||
order: 0,
|
||||
chapterTitle: "Chapter 1",
|
||||
questionCount: 0,
|
||||
prerequisiteIds,
|
||||
})
|
||||
|
||||
describe("computeGraphLayout", () => {
|
||||
@@ -17,79 +25,40 @@ describe("textbooks/graph-layout", () => {
|
||||
const layout = computeGraphLayout([])
|
||||
expect(layout.nodes).toEqual([])
|
||||
expect(layout.edges).toEqual([])
|
||||
expect(layout.width).toBe(0)
|
||||
expect(layout.height).toBe(0)
|
||||
})
|
||||
|
||||
it("should place single root node", () => {
|
||||
it("should place single node", () => {
|
||||
const layout = computeGraphLayout([makeKp("1")])
|
||||
expect(layout.nodes).toHaveLength(1)
|
||||
expect(layout.nodes[0].x).toBe(GAP_X)
|
||||
expect(layout.nodes[0].y).toBe(GAP_Y)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
expect(layout.nodes[0].id).toBe("1")
|
||||
})
|
||||
|
||||
it("should compute parent-child layout with edge", () => {
|
||||
it("should generate parent-child edge", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
expect(layout.edges).toHaveLength(1)
|
||||
expect(layout.edges[0].id).toBe("1-2")
|
||||
const parentEdge = layout.edges.find((e) => e.id === "parent-1-2")
|
||||
expect(parentEdge).toBeDefined()
|
||||
})
|
||||
|
||||
it("should place children at lower level (higher y)", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2", "1")])
|
||||
const root = layout.nodes.find((n) => n.id === "1")
|
||||
const child = layout.nodes.find((n) => n.id === "2")
|
||||
expect(child!.y).toBeGreaterThan(root!.y)
|
||||
})
|
||||
|
||||
it("should handle multiple roots", () => {
|
||||
const layout = computeGraphLayout([makeKp("1"), makeKp("2")])
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
const n1 = layout.nodes.find((n) => n.id === "1")
|
||||
const n2 = layout.nodes.find((n) => n.id === "2")
|
||||
expect(n2!.x).toBeGreaterThan(n1!.x)
|
||||
})
|
||||
|
||||
it("should handle circular references gracefully (no infinite loop)", () => {
|
||||
// a → b → a 循环
|
||||
const kps = [makeKp("a", "b"), makeKp("b", "a")]
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should handle all nodes referencing non-existent parent", () => {
|
||||
const kps = [makeKp("1", "nonexistent"), makeKp("2", "nonexistent")]
|
||||
const layout = computeGraphLayout(kps)
|
||||
expect(layout.nodes).toHaveLength(2)
|
||||
// 全部作为根节点处理
|
||||
expect(layout.edges).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should compute width based on max nodes per level", () => {
|
||||
const kps = [
|
||||
it("should generate prerequisite edge", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2"),
|
||||
makeKp("3"),
|
||||
makeKp("4", "1"),
|
||||
]
|
||||
const layout = computeGraphLayout(kps)
|
||||
// 第 0 层有 3 个节点,是最大层
|
||||
const expectedWidth = 3 * (NODE_WIDTH + GAP_X) + GAP_X
|
||||
expect(layout.width).toBe(expectedWidth)
|
||||
makeKp("2", null, ["1"]),
|
||||
])
|
||||
const prereqEdge = layout.edges.find((e) => e.id === "prereq-1-2")
|
||||
expect(prereqEdge).toBeDefined()
|
||||
})
|
||||
|
||||
it("should compute height based on level count", () => {
|
||||
const kps = [
|
||||
it("should assign positions to all nodes", () => {
|
||||
const layout = computeGraphLayout([
|
||||
makeKp("1"),
|
||||
makeKp("2", "1"),
|
||||
makeKp("3", "2"),
|
||||
]
|
||||
const layout = computeGraphLayout(kps)
|
||||
// 3 层
|
||||
const expectedHeight = 3 * (NODE_HEIGHT + GAP_Y) + GAP_Y
|
||||
expect(layout.height).toBe(expectedHeight)
|
||||
makeKp("3", "1"),
|
||||
])
|
||||
for (const node of layout.nodes) {
|
||||
expect(node.position.x).toBeGreaterThanOrEqual(0)
|
||||
expect(node.position.y).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user