feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

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
This commit is contained in:
SpecialX
2026-07-03 10:26:38 +08:00
parent 21142f9b99
commit 0e63c24ed9
97 changed files with 9753 additions and 819 deletions

View File

@@ -0,0 +1,314 @@
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:0015: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()
})
})