shared: - Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components - Add ui/alert component - Add constants directory - Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib - Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac tests: - Add integration tests for elective - Add tests/setup/empty-stub scripts: - Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest"
|
||
|
||
import {
|
||
parseSchedule,
|
||
isScheduleConflict,
|
||
normalizeDay,
|
||
buildLotteryRankCase,
|
||
} from "@/modules/elective/data-access-operations"
|
||
import { mapCourseRow, type CourseCoreRow } from "@/modules/elective/data-access"
|
||
|
||
/**
|
||
* Elective 模块纯函数单元测试(P2-18 新增)。
|
||
*
|
||
* 覆盖范围:
|
||
* - normalizeDay:中英文星期归一化
|
||
* - parseSchedule:时间段解析(单时段、多时段、中英文、非法输入)
|
||
* - isScheduleConflict:冲突检测(同天重叠、跨天不冲突、归一化后冲突)
|
||
* - buildLotteryRankCase:抽签 rank SQL 构建结构
|
||
* - mapCourseRow:数据库行 → 业务对象映射
|
||
*
|
||
* 这些纯函数不依赖 DB,可在 vitest node 环境下独立运行。
|
||
*/
|
||
|
||
describe("normalizeDay", () => {
|
||
it("maps Chinese 周一-周天 to 1-7", () => {
|
||
expect(normalizeDay("周一")).toBe("1")
|
||
expect(normalizeDay("周二")).toBe("2")
|
||
expect(normalizeDay("周三")).toBe("3")
|
||
expect(normalizeDay("周四")).toBe("4")
|
||
expect(normalizeDay("周五")).toBe("5")
|
||
expect(normalizeDay("周六")).toBe("6")
|
||
expect(normalizeDay("周日")).toBe("7")
|
||
expect(normalizeDay("周天")).toBe("7")
|
||
})
|
||
|
||
it("maps Chinese 星期一-星期日 to 1-7", () => {
|
||
expect(normalizeDay("星期一")).toBe("1")
|
||
expect(normalizeDay("星期二")).toBe("2")
|
||
expect(normalizeDay("星期日")).toBe("7")
|
||
expect(normalizeDay("星期天")).toBe("7")
|
||
})
|
||
|
||
it("maps English full day names case-insensitively", () => {
|
||
expect(normalizeDay("monday")).toBe("1")
|
||
expect(normalizeDay("Tuesday")).toBe("2")
|
||
expect(normalizeDay("WEDNESDAY")).toBe("3")
|
||
expect(normalizeDay("Thursday")).toBe("4")
|
||
expect(normalizeDay("friday")).toBe("5")
|
||
expect(normalizeDay("Saturday")).toBe("6")
|
||
expect(normalizeDay("sunday")).toBe("7")
|
||
})
|
||
|
||
it("maps English abbreviations case-insensitively", () => {
|
||
expect(normalizeDay("mon")).toBe("1")
|
||
expect(normalizeDay("Tue")).toBe("2")
|
||
expect(normalizeDay("WED")).toBe("3")
|
||
expect(normalizeDay("thu")).toBe("4")
|
||
expect(normalizeDay("fri")).toBe("5")
|
||
expect(normalizeDay("sat")).toBe("6")
|
||
expect(normalizeDay("sun")).toBe("7")
|
||
})
|
||
|
||
it("returns input unchanged for unknown values", () => {
|
||
expect(normalizeDay("foo")).toBe("foo")
|
||
expect(normalizeDay("")).toBe("")
|
||
})
|
||
})
|
||
|
||
describe("parseSchedule", () => {
|
||
it("returns empty array for null/empty input", () => {
|
||
expect(parseSchedule(null)).toEqual([])
|
||
expect(parseSchedule("")).toEqual([])
|
||
expect(parseSchedule(" ")).toEqual([])
|
||
})
|
||
|
||
it("parses single Chinese schedule", () => {
|
||
const result = parseSchedule("周一 14:00-15:30")
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]).toEqual({
|
||
day: "周一",
|
||
start: "14:00",
|
||
end: "15:30",
|
||
})
|
||
})
|
||
|
||
it("parses single English full day name", () => {
|
||
const result = parseSchedule("Monday 09:00-10:30")
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]).toEqual({
|
||
day: "Monday",
|
||
start: "09:00",
|
||
end: "10:30",
|
||
})
|
||
})
|
||
|
||
it("parses single English abbreviation", () => {
|
||
const result = parseSchedule("Mon 14:00-15:30")
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]).toEqual({
|
||
day: "Mon",
|
||
start: "14:00",
|
||
end: "15:30",
|
||
})
|
||
})
|
||
|
||
it("parses 星期一 format", () => {
|
||
const result = parseSchedule("星期一 08:00-09:00")
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]).toEqual({
|
||
day: "星期一",
|
||
start: "08:00",
|
||
end: "09:00",
|
||
})
|
||
})
|
||
|
||
it("parses multiple time slots separated by comma", () => {
|
||
const result = parseSchedule("周一 14:00-15:30, Wed 16:00-17:30")
|
||
expect(result).toHaveLength(2)
|
||
expect(result[0]).toEqual({ day: "周一", start: "14:00", end: "15:30" })
|
||
expect(result[1]).toEqual({ day: "Wed", start: "16:00", end: "17:30" })
|
||
})
|
||
|
||
it("parses multiple time slots separated by Chinese semicolon", () => {
|
||
const result = parseSchedule("周一 14:00-15:30;周三 16:00-17:30")
|
||
expect(result).toHaveLength(2)
|
||
expect(result[0]).toEqual({ day: "周一", start: "14:00", end: "15:30" })
|
||
expect(result[1]).toEqual({ day: "周三", start: "16:00", end: "17:30" })
|
||
})
|
||
|
||
it("parses multiple time slots separated by English semicolon", () => {
|
||
const result = parseSchedule("Mon 14:00-15:30; Wed 16:00-17:30")
|
||
expect(result).toHaveLength(2)
|
||
})
|
||
|
||
it("supports ~ and ~ and 至 as time range separators", () => {
|
||
expect(parseSchedule("周一 14:00~15:30")).toHaveLength(1)
|
||
expect(parseSchedule("周一 14:00~15:30")).toHaveLength(1)
|
||
expect(parseSchedule("周一 14:00至15:30")).toHaveLength(1)
|
||
})
|
||
|
||
it("pads single-digit hour with leading zero", () => {
|
||
const result = parseSchedule("周一 9:00-10:30")
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0]?.start).toBe("09:00")
|
||
expect(result[0]?.end).toBe("10:30")
|
||
})
|
||
|
||
it("returns empty array for invalid format", () => {
|
||
expect(parseSchedule("invalid")).toEqual([])
|
||
expect(parseSchedule("周一")).toEqual([])
|
||
expect(parseSchedule("周一 14:00")).toEqual([])
|
||
expect(parseSchedule("周一 invalid-invalid")).toEqual([])
|
||
})
|
||
|
||
it("skips invalid segments in multi-slot input but keeps valid ones", () => {
|
||
const result = parseSchedule("周一 14:00-15:30, invalid, Wed 16:00-17:30")
|
||
expect(result).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
describe("isScheduleConflict", () => {
|
||
it("returns false for different days", () => {
|
||
const a = { day: "周一", start: "14:00", end: "15:30" }
|
||
const b = { day: "周二", start: "14:00", end: "15:30" }
|
||
expect(isScheduleConflict(a, b)).toBe(false)
|
||
})
|
||
|
||
it("returns true for overlapping time on same day (Chinese)", () => {
|
||
const a = { day: "周一", start: "14:00", end: "15:30" }
|
||
const b = { day: "周一", start: "15:00", end: "16:00" }
|
||
expect(isScheduleConflict(a, b)).toBe(true)
|
||
})
|
||
|
||
it("returns false for adjacent non-overlapping on same day", () => {
|
||
const a = { day: "周一", start: "14:00", end: "15:30" }
|
||
const b = { day: "周一", start: "15:30", end: "17:00" }
|
||
expect(isScheduleConflict(a, b)).toBe(false)
|
||
})
|
||
|
||
it("normalizes day before comparison (周一 vs Mon)", () => {
|
||
const a = { day: "周一", start: "14:00", end: "15:30" }
|
||
const b = { day: "Mon", start: "15:00", end: "16:00" }
|
||
expect(isScheduleConflict(a, b)).toBe(true)
|
||
})
|
||
|
||
it("normalizes day before comparison (Monday vs 星期一)", () => {
|
||
const a = { day: "Monday", start: "14:00", end: "15:30" }
|
||
const b = { day: "星期一", start: "15:00", end: "16:00" }
|
||
expect(isScheduleConflict(a, b)).toBe(true)
|
||
})
|
||
|
||
it("returns false for non-overlapping on same day", () => {
|
||
const a = { day: "周一", start: "08:00", end: "09:00" }
|
||
const b = { day: "周一", start: "14:00", end: "15:30" }
|
||
expect(isScheduleConflict(a, b)).toBe(false)
|
||
})
|
||
|
||
it("handles contained intervals", () => {
|
||
const a = { day: "周一", start: "14:00", end: "17:00" }
|
||
const b = { day: "周一", start: "15:00", end: "16:00" }
|
||
expect(isScheduleConflict(a, b)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe("buildLotteryRankCase", () => {
|
||
it("returns SQL object for empty input", () => {
|
||
const sql = buildLotteryRankCase([], 1)
|
||
expect(sql).toBeDefined()
|
||
})
|
||
|
||
it("returns SQL object for non-empty input", () => {
|
||
const sql = buildLotteryRankCase(["id1", "id2", "id3"], 5)
|
||
expect(sql).toBeDefined()
|
||
})
|
||
|
||
it("preserves order and start rank", () => {
|
||
// SQL 对象本身不可直接断言内部结构,但应能成功构建
|
||
const ids = ["a", "b", "c", "d"]
|
||
const sql = buildLotteryRankCase(ids, 10)
|
||
expect(sql).toBeDefined()
|
||
// 不抛异常即视为通过;实际 SQL 执行由集成测试覆盖
|
||
})
|
||
})
|
||
|
||
describe("mapCourseRow", () => {
|
||
// 构造一个最小的 mock 行(仅包含 mapCourseRow 使用的字段)
|
||
const mockRow: CourseCoreRow = {
|
||
id: "course-1",
|
||
name: "Python 入门",
|
||
subjectId: "subj-1",
|
||
teacherId: "user-1",
|
||
gradeId: "grade-1",
|
||
description: "Python 编程基础",
|
||
capacity: 30,
|
||
enrolledCount: 25,
|
||
classroom: "A101",
|
||
schedule: "周一 14:00-15:30",
|
||
startDate: new Date("2026-09-01"),
|
||
endDate: new Date("2027-01-15"),
|
||
selectionStartAt: new Date("2026-08-20T08:00:00Z"),
|
||
selectionEndAt: new Date("2026-08-25T18:00:00Z"),
|
||
status: "open",
|
||
selectionMode: "lottery",
|
||
credit: "2.0",
|
||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||
updatedAt: new Date("2026-06-15T12:00:00Z"),
|
||
}
|
||
|
||
it("maps basic fields correctly", () => {
|
||
const result = mapCourseRow(
|
||
mockRow,
|
||
new Map(),
|
||
new Map(),
|
||
new Map()
|
||
)
|
||
expect(result.id).toBe("course-1")
|
||
expect(result.name).toBe("Python 入门")
|
||
expect(result.capacity).toBe(30)
|
||
expect(result.enrolledCount).toBe(25)
|
||
expect(result.status).toBe("open")
|
||
expect(result.selectionMode).toBe("lottery")
|
||
})
|
||
|
||
it("converts credit to string", () => {
|
||
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
|
||
expect(result.credit).toBe("2.0")
|
||
expect(typeof result.credit).toBe("string")
|
||
})
|
||
|
||
it("formats startDate as YYYY-MM-DD", () => {
|
||
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
|
||
expect(result.startDate).toBe("2026-09-01")
|
||
})
|
||
|
||
it("converts timestamps to ISO strings", () => {
|
||
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
|
||
expect(result.createdAt).toBe("2026-06-01T00:00:00.000Z")
|
||
expect(result.updatedAt).toBe("2026-06-15T12:00:00.000Z")
|
||
expect(result.selectionStartAt).toBe("2026-08-20T08:00:00.000Z")
|
||
})
|
||
|
||
it("resolves teacherName from map", () => {
|
||
const teacherNames = new Map([["user-1", "张老师"]])
|
||
const result = mapCourseRow(mockRow, teacherNames, new Map(), new Map())
|
||
expect(result.teacherName).toBe("张老师")
|
||
})
|
||
|
||
it("returns null teacherName when teacherId not in map", () => {
|
||
const result = mapCourseRow(mockRow, new Map(), new Map(), new Map())
|
||
expect(result.teacherName).toBeNull()
|
||
})
|
||
|
||
it("resolves subjectName and gradeName from maps", () => {
|
||
const teacherNames = new Map([["user-1", "张老师"]])
|
||
const subjectNames = new Map([["subj-1", "计算机"]])
|
||
const gradeNames = new Map([["grade-1", "高一"]])
|
||
const result = mapCourseRow(mockRow, teacherNames, subjectNames, gradeNames)
|
||
expect(result.subjectName).toBe("计算机")
|
||
expect(result.gradeName).toBe("高一")
|
||
})
|
||
|
||
it("returns null subjectName/gradeName when ids are null", () => {
|
||
const rowWithNulls: CourseCoreRow = {
|
||
...mockRow,
|
||
subjectId: null,
|
||
gradeId: null,
|
||
}
|
||
const result = mapCourseRow(rowWithNulls, new Map(), new Map(), new Map())
|
||
expect(result.subjectName).toBeNull()
|
||
expect(result.gradeName).toBeNull()
|
||
expect(result.subjectId).toBeNull()
|
||
expect(result.gradeId).toBeNull()
|
||
})
|
||
})
|