Files
NextEdu/src/modules/textbooks/utils.test.ts
SpecialX e2e0487a3b feat(attendance,elective): 实现所有 P2 长期改进项
P2 修复(来自审计报告):
- 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action)
- 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面)
- 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页)
- 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid)
- 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页)
- 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重)
- 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入)

P2 建议项:
- 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict)
- 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit)
- 考勤/选课数据导出 Excel(export.ts + API 路由扩展)

新增文件:
- src/modules/attendance/components/attendance-page-layout.tsx
- src/modules/elective/components/elective-page-layout.tsx
- src/modules/elective/resolvers.ts
- src/modules/attendance/export.ts
- src/modules/elective/export.ts

校验:
- npm run lint 通过(exit 0)
- npx tsc --noEmit attendance/elective/parent 相关零错误
2026-06-23 09:02:41 +08:00

286 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest"
import type { Chapter, KnowledgePoint } from "./types"
import {
buildChapterTree,
buildChapterIndex,
findChapterParent,
filterKnowledgePointsByChapter,
normalizeOptional,
sortChapters,
highlightKnowledgePoints,
hasCycleAfterAddingEdge,
} 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")
})
})
describe("highlightKnowledgePoints", () => {
const makeKp = (id: string, name: string): KnowledgePoint => ({
id,
name,
chapterId: "c1",
level: 1,
order: 0,
})
it("should return content unchanged when no knowledge points", () => {
expect(highlightKnowledgePoints("hello world", [])).toBe("hello world")
})
it("should return content unchanged when content is empty", () => {
expect(highlightKnowledgePoints("", [makeKp("1", "test")])).toBe("")
})
it("should wrap single knowledge point name with link", () => {
const result = highlightKnowledgePoints("学习物理很有趣", [makeKp("kp1", "物理")])
expect(result).toBe("学习[物理](#kp-kp1)很有趣")
})
it("should match case-insensitively", () => {
const result = highlightKnowledgePoints("Hello HELLO hello", [makeKp("kp1", "hello")])
expect(result).toBe("[Hello](#kp-kp1) [HELLO](#kp-kp1) [hello](#kp-kp1)")
})
it("should match multiple occurrences", () => {
const result = highlightKnowledgePoints("物理物理物理", [makeKp("kp1", "物理")])
expect(result).toBe("[物理](#kp-kp1)[物理](#kp-kp1)[物理](#kp-kp1)")
})
it("should prioritize longest match (物理学 before 物理)", () => {
const kps = [makeKp("kp1", "物理"), makeKp("kp2", "物理学")]
const result = highlightKnowledgePoints("物理学是研究物理的学科", kps)
// "物理学" 应被 kp2 匹配,独立的 "物理" 应被 kp1 匹配
expect(result).toBe("[物理学](#kp-kp2)是研究[物理](#kp-kp1)的学科")
})
it("should handle multiple different knowledge points in one pass", () => {
const kps = [makeKp("kp1", "数学"), makeKp("kp2", "物理")]
const result = highlightKnowledgePoints("数学和物理都是科学", kps)
expect(result).toBe("[数学](#kp-kp1)和[物理](#kp-kp2)都是科学")
})
it("should escape regex metacharacters in knowledge point names", () => {
const result = highlightKnowledgePoints("计算 1+2 的结果", [makeKp("kp1", "1+2")])
expect(result).toBe("计算 [1+2](#kp-kp1) 的结果")
})
it("should handle knowledge point names with parentheses", () => {
const result = highlightKnowledgePoints("函数 f(x) 是映射", [makeKp("kp1", "f(x)")])
expect(result).toBe("函数 [f(x)](#kp-kp1) 是映射")
})
})
})
describe("textbooks/utils - cycle detection", () => {
it("should return false when adding edge to empty graph", () => {
const edges: Array<[string, string]> = []
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
})
it("should detect direct cycle (a->b then b->a)", () => {
const edges: Array<[string, string]> = [["a", "b"]]
expect(hasCycleAfterAddingEdge(edges, "b", "a")).toBe(true)
})
it("should detect indirect cycle (a->b->c then c->a)", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
expect(hasCycleAfterAddingEdge(edges, "c", "a")).toBe(true)
})
it("should not detect cycle for independent chains", () => {
const edges: Array<[string, string]> = [["a", "b"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "b", "c")).toBe(false)
})
it("should not detect cycle for diamond shape", () => {
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
it("should detect self-loop as cycle (a->a)", () => {
expect(hasCycleAfterAddingEdge([], "a", "a")).toBe(true)
})
it("should not detect cycle for duplicate edge (a->b already exists)", () => {
const edges: Array<[string, string]> = [["a", "b"]]
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
})
it("should detect longer indirect cycle (a->b->c->d then d->a)", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "a")).toBe(true)
})
it("should not detect cycle when adding edge to unrelated node", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
})