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,181 @@
import { describe, it, expect } from "vitest"
import type { Chapter, KnowledgePoint } from "./types"
import {
buildChapterTree,
buildChapterIndex,
findChapterParent,
filterKnowledgePointsByChapter,
normalizeOptional,
sortChapters,
} from "./utils"
// 测试辅助:构造最小合法 Chapter补齐 createdAt/updatedAt 等必填字段)
const makeChapter = (over: Partial<Chapter> & Pick<Chapter, "id" | "title" | "textbookId">): Chapter => ({
order: 0,
parentId: null,
content: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...over,
})
describe("textbooks/utils", () => {
describe("sortChapters", () => {
it("should sort by order ascending", () => {
const a = makeChapter({ id: "1", title: "A", order: 2, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "B", order: 1, textbookId: "t1" })
expect(sortChapters(a, b)).toBeGreaterThan(0)
})
it("should fall back to title localeCompare when order equal", () => {
const a = makeChapter({ id: "1", title: "Banana", order: 1, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "Apple", order: 1, textbookId: "t1" })
expect(sortChapters(a, b)).toBeGreaterThan(0)
})
it("should treat null order as 0", () => {
const a = makeChapter({ id: "1", title: "A", order: null, textbookId: "t1" })
const b = makeChapter({ id: "2", title: "B", order: 5, textbookId: "t1" })
expect(sortChapters(a, b)).toBeLessThan(0)
})
})
describe("buildChapterTree", () => {
it("should return empty array for empty input", () => {
expect(buildChapterTree([])).toEqual([])
})
it("should build single root", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].id).toBe("1")
expect(tree[0].children).toEqual([])
})
it("should build nested tree", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
makeChapter({ id: "2", title: "Child 1", order: 0, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "3", title: "Child 2", order: 1, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "4", title: "Grandchild", order: 0, textbookId: "t1", parentId: "2" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].children).toHaveLength(2)
expect(tree[0].children[0].children).toHaveLength(1)
expect(tree[0].children[0].children[0].id).toBe("4")
})
it("should handle orphan nodes (parentId points to non-existent)", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Orphan", order: 0, textbookId: "t1", parentId: "nonexistent" }),
]
const tree = buildChapterTree(rows)
expect(tree).toHaveLength(1)
expect(tree[0].id).toBe("1")
})
it("should sort children by order", () => {
const rows: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1" }),
makeChapter({ id: "2", title: "B", order: 2, textbookId: "t1", parentId: "1" }),
makeChapter({ id: "3", title: "A", order: 1, textbookId: "t1", parentId: "1" }),
]
const tree = buildChapterTree(rows)
expect(tree[0].children[0].id).toBe("3")
expect(tree[0].children[1].id).toBe("2")
})
})
describe("buildChapterIndex", () => {
it("should index all nodes including nested", () => {
const chapters: Chapter[] = [
makeChapter({
id: "1",
title: "Root",
order: 0,
textbookId: "t1",
children: [
makeChapter({ id: "2", title: "Child", order: 0, textbookId: "t1", parentId: "1", children: [] }),
],
}),
]
const index = buildChapterIndex(chapters)
expect(index.size).toBe(2)
expect(index.get("1")?.title).toBe("Root")
expect(index.get("2")?.title).toBe("Child")
})
it("should return empty map for empty input", () => {
expect(buildChapterIndex([]).size).toBe(0)
})
})
describe("findChapterParent", () => {
it("should find direct parent", () => {
const chapters: Chapter[] = [
makeChapter({
id: "1",
title: "Root",
order: 0,
textbookId: "t1",
children: [
makeChapter({ id: "2", title: "Child", order: 0, textbookId: "t1", parentId: "1", children: [] }),
],
}),
]
const parent = findChapterParent(chapters, "2")
expect(parent?.id).toBe("1")
})
it("should return null for root node", () => {
const chapters: Chapter[] = [
makeChapter({ id: "1", title: "Root", order: 0, textbookId: "t1", children: [] }),
]
expect(findChapterParent(chapters, "1")).toBeNull()
})
it("should return null for non-existent id", () => {
expect(findChapterParent([], "nonexistent")).toBeNull()
})
})
describe("filterKnowledgePointsByChapter", () => {
it("should return empty for null chapterId", () => {
expect(filterKnowledgePointsByChapter([], null)).toEqual([])
})
it("should filter by chapterId", () => {
const kps: KnowledgePoint[] = [
{ id: "1", name: "KP1", chapterId: "c1", level: 1, order: 0 },
{ id: "2", name: "KP2", chapterId: "c2", level: 1, order: 0 },
{ id: "3", name: "KP3", chapterId: "c1", level: 2, order: 0 },
]
const result = filterKnowledgePointsByChapter(kps, "c1")
expect(result).toHaveLength(2)
expect(result[0].id).toBe("1")
})
})
describe("normalizeOptional", () => {
it("should return null for empty string", () => {
expect(normalizeOptional("")).toBeNull()
})
it("should return null for whitespace-only string", () => {
expect(normalizeOptional(" ")).toBeNull()
})
it("should return null for null/undefined", () => {
expect(normalizeOptional(null)).toBeNull()
expect(normalizeOptional(undefined)).toBeNull()
})
it("should trim and return non-empty string", () => {
expect(normalizeOptional(" hello ")).toBe("hello")
})
})
})