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:
181
src/modules/textbooks/utils.test.ts
Normal file
181
src/modules/textbooks/utils.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user