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 & Pick): 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) }) })