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:
SpecialX
2026-06-22 16:25:59 +08:00
parent 45ee1ae43c
commit 22d3f07fcf
35 changed files with 2043 additions and 792 deletions

View 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)
})
})
})