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 相关零错误
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
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)
|
||
})
|
||
})
|