feat(textbooks): 教材模块审计重构 — 跨模块解耦 + 权限 + i18n + 错误边界 + 纯函数抽取
P0 修复: - 解耦跨模块 UI 依赖:knowledge-point-dialogs 不再直接 import questions, 改为 renderQuestionCreator render prop 由页面注入 - 接入 usePermission Hook 替换 canEdit 硬编码 - 全模块 i18n 改造:新增 en/zh-CN 翻译文件,替换所有硬编码文案 - Server Action 资源归属校验:新增 verifyChapterBelongsToTextbook/ verifyKnowledgePointBelongsToTextbook,在 reorder/update/delete/create 中校验 P1 改进: - 补齐 Error Boundary:4 个 error.tsx + TextbookSectionErrorBoundary 区块包裹 - 抽取纯函数到 utils.ts/graph-layout.ts/constants.ts 并补单测(26 用例全通过) - 消除重复组件:删除 knowledge-point-panel/create-knowledge-point-dialog - 修复类型断言:chapter.children! → 守卫式访问 - 图谱 a11y:添加 role/aria-label/aria-pressed - 统一删除确认:confirm() → AlertDialog - 数据范围过滤:getTextbooksWithScope 支持学生端按年级过滤 P2 预留: - TextbookAnalytics 埋点接口 + Provider + Hook 同步 005 架构数据 JSON:补充 getTextbooksWithScope/verify*/ChapterTreeNode 等
This commit is contained in:
95
src/modules/textbooks/graph-layout.test.ts
Normal file
95
src/modules/textbooks/graph-layout.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { KnowledgePoint } from "./types"
|
||||
import { computeGraphLayout, NODE_WIDTH, NODE_HEIGHT, GAP_X, GAP_Y } from "./graph-layout"
|
||||
|
||||
describe("textbooks/graph-layout", () => {
|
||||
const makeKp = (id: string, parentId: string | null = null): KnowledgePoint => ({
|
||||
id,
|
||||
name: `KP-${id}`,
|
||||
chapterId: "c1",
|
||||
parentId,
|
||||
level: 1,
|
||||
order: 0,
|
||||
})
|
||||
|
||||
describe("computeGraphLayout", () => {
|
||||
it("should return empty layout for empty input", () => {
|
||||
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", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it("should compute parent-child layout with 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")
|
||||
})
|
||||
|
||||
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 = [
|
||||
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)
|
||||
})
|
||||
|
||||
it("should compute height based on level count", () => {
|
||||
const kps = [
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user