refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y
V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复: - P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键 - P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student 头部 90% 重复代码,两个 Header 改为薄包装 - P2 单测:为 6 个纯函数添加 31 个单元测试 (tests/integration/dashboard/dashboard-utils.test.ts) - P2 a11y:admin 表格 caption、teacher/student 视图语义化标签 (header / section aria-label / aside aria-label) - 同步架构图 004/005
This commit is contained in:
408
tests/integration/dashboard/dashboard-utils.test.ts
Normal file
408
tests/integration/dashboard/dashboard-utils.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import {
|
||||
toWeekday,
|
||||
countStudentAssignments,
|
||||
sortUpcomingAssignments,
|
||||
filterTodaySchedule,
|
||||
computeTeacherMetrics,
|
||||
getGreetingKey,
|
||||
type Weekday,
|
||||
} from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
|
||||
import type {
|
||||
HomeworkAssignmentListItem,
|
||||
HomeworkSubmissionListItem,
|
||||
TeacherGradeTrendItem,
|
||||
} from "@/modules/homework/types"
|
||||
|
||||
describe("toWeekday", () => {
|
||||
it("returns 1 for Monday", () => {
|
||||
const monday = new Date("2026-06-22T00:00:00") // 2026-06-22 is Monday
|
||||
expect(toWeekday(monday)).toBe(1 as Weekday)
|
||||
})
|
||||
|
||||
it("returns 7 for Sunday", () => {
|
||||
const sunday = new Date("2026-06-28T00:00:00") // 2026-06-28 is Sunday
|
||||
expect(toWeekday(sunday)).toBe(7 as Weekday)
|
||||
})
|
||||
|
||||
it("returns 6 for Saturday", () => {
|
||||
const saturday = new Date("2026-06-27T00:00:00") // 2026-06-27 is Saturday
|
||||
expect(toWeekday(saturday)).toBe(6 as Weekday)
|
||||
})
|
||||
|
||||
it("returns 2 for Tuesday", () => {
|
||||
const tuesday = new Date("2026-06-23T00:00:00") // 2026-06-23 is Tuesday
|
||||
expect(toWeekday(tuesday)).toBe(2 as Weekday)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGreetingKey", () => {
|
||||
it("returns morning before 12:00", () => {
|
||||
expect(getGreetingKey(new Date("2026-06-22T08:00:00"))).toBe("morning")
|
||||
expect(getGreetingKey(new Date("2026-06-22T11:59:59"))).toBe("morning")
|
||||
})
|
||||
|
||||
it("returns afternoon between 12:00 and 18:00", () => {
|
||||
expect(getGreetingKey(new Date("2026-06-22T12:00:00"))).toBe("afternoon")
|
||||
expect(getGreetingKey(new Date("2026-06-22T17:59:59"))).toBe("afternoon")
|
||||
})
|
||||
|
||||
it("returns evening at 18:00 or later", () => {
|
||||
expect(getGreetingKey(new Date("2026-06-22T18:00:00"))).toBe("evening")
|
||||
expect(getGreetingKey(new Date("2026-06-22T23:59:59"))).toBe("evening")
|
||||
})
|
||||
})
|
||||
|
||||
describe("countStudentAssignments", () => {
|
||||
const now = new Date("2026-06-22T10:00:00")
|
||||
|
||||
it("returns zero counts for empty list", () => {
|
||||
expect(countStudentAssignments([], now)).toEqual({
|
||||
dueSoonCount: 0,
|
||||
overdueCount: 0,
|
||||
gradedCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("counts graded assignments", () => {
|
||||
const assignments = [
|
||||
makeAssignment("1", "graded", null),
|
||||
makeAssignment("2", "graded", null),
|
||||
]
|
||||
expect(countStudentAssignments(assignments, now)).toEqual({
|
||||
dueSoonCount: 0,
|
||||
overdueCount: 0,
|
||||
gradedCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("counts due soon (within 7 days)", () => {
|
||||
const in3Days = new Date(now)
|
||||
in3Days.setDate(in3Days.getDate() + 3)
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", in3Days.toISOString()),
|
||||
]
|
||||
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(1)
|
||||
})
|
||||
|
||||
it("counts overdue", () => {
|
||||
const past = new Date(now)
|
||||
past.setDate(past.getDate() - 1)
|
||||
const assignments = [
|
||||
makeAssignment("1", "submitted", past.toISOString()),
|
||||
]
|
||||
expect(countStudentAssignments(assignments, now).overdueCount).toBe(1)
|
||||
})
|
||||
|
||||
it("ignores assignments without dueAt (non-graded)", () => {
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", null),
|
||||
]
|
||||
expect(countStudentAssignments(assignments, now)).toEqual({
|
||||
dueSoonCount: 0,
|
||||
overdueCount: 0,
|
||||
gradedCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it("respects custom dueSoonWindowDays", () => {
|
||||
const in15Days = new Date(now)
|
||||
in15Days.setDate(in15Days.getDate() + 15)
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", in15Days.toISOString()),
|
||||
]
|
||||
// default 7-day window: not due soon
|
||||
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(0)
|
||||
// 30-day window: due soon
|
||||
expect(countStudentAssignments(assignments, now, 30).dueSoonCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortUpcomingAssignments", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(sortUpcomingAssignments([])).toEqual([])
|
||||
})
|
||||
|
||||
it("sorts by dueAt ascending", () => {
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
|
||||
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||
makeAssignment("3", "in_progress", "2026-06-24T00:00:00"),
|
||||
]
|
||||
const sorted = sortUpcomingAssignments(assignments)
|
||||
expect(sorted.map((a) => a.id)).toEqual(["2", "3", "1"])
|
||||
})
|
||||
|
||||
it("puts assignments without dueAt at the end", () => {
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", null),
|
||||
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||
]
|
||||
const sorted = sortUpcomingAssignments(assignments)
|
||||
expect(sorted.map((a) => a.id)).toEqual(["2", "1"])
|
||||
})
|
||||
|
||||
it("respects limit parameter", () => {
|
||||
const assignments = Array.from({ length: 10 }, (_, i) =>
|
||||
makeAssignment(`${i}`, "in_progress", `2026-06-${23 + i}T00:00:00`),
|
||||
)
|
||||
expect(sortUpcomingAssignments(assignments, 3)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("does not mutate original array", () => {
|
||||
const assignments = [
|
||||
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
|
||||
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||
]
|
||||
sortUpcomingAssignments(assignments)
|
||||
expect(assignments.map((a) => a.id)).toEqual(["1", "2"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterTodaySchedule", () => {
|
||||
const weekday = 1 as Weekday // Monday
|
||||
|
||||
it("returns empty array for empty schedule", () => {
|
||||
expect(filterTodaySchedule([], weekday)).toEqual([])
|
||||
})
|
||||
|
||||
it("filters by weekday", () => {
|
||||
const schedule: ClassScheduleItem[] = [
|
||||
makeScheduleItem("1", 1, "08:00"),
|
||||
makeScheduleItem("2", 2, "09:00"),
|
||||
makeScheduleItem("3", 1, "07:00"),
|
||||
]
|
||||
const result = filterTodaySchedule(schedule, weekday)
|
||||
expect(result).toHaveLength(2)
|
||||
expect((result as Array<{ id: string }>).map((s) => s.id)).toEqual(["3", "1"])
|
||||
})
|
||||
|
||||
it("sorts by startTime ascending", () => {
|
||||
const schedule: ClassScheduleItem[] = [
|
||||
makeScheduleItem("1", 1, "10:00"),
|
||||
makeScheduleItem("2", 1, "08:00"),
|
||||
makeScheduleItem("3", 1, "09:00"),
|
||||
]
|
||||
const result = filterTodaySchedule(schedule, weekday) as Array<{ id: string }>
|
||||
expect(result.map((s) => s.id)).toEqual(["2", "3", "1"])
|
||||
})
|
||||
|
||||
it("uses classNameById map when provided", () => {
|
||||
const schedule: ClassScheduleItem[] = [
|
||||
makeScheduleItem("1", 1, "08:00"),
|
||||
]
|
||||
const classNameById = new Map([["class-1", "Math 101"]])
|
||||
const result = filterTodaySchedule(schedule, weekday, classNameById) as Array<{ className: string }>
|
||||
expect(result[0].className).toBe("Math 101")
|
||||
})
|
||||
|
||||
it("falls back to 'Class' when classNameById missing", () => {
|
||||
const schedule: ClassScheduleItem[] = [
|
||||
makeScheduleItem("1", 1, "08:00"),
|
||||
]
|
||||
const result = filterTodaySchedule(schedule, weekday) as Array<{ className: string }>
|
||||
expect(result[0].className).toBe("Class")
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeTeacherMetrics", () => {
|
||||
const now = new Date("2026-06-22T10:00:00") // Monday
|
||||
|
||||
it("returns zero metrics for empty inputs", () => {
|
||||
const metrics = computeTeacherMetrics([], [], [], [], [], now)
|
||||
expect(metrics.toGradeCount).toBe(0)
|
||||
expect(metrics.activeAssignmentsCount).toBe(0)
|
||||
expect(metrics.averageScore).toBe(0)
|
||||
expect(metrics.submissionRate).toBe(0)
|
||||
expect(metrics.todayScheduleItems).toEqual([])
|
||||
expect(metrics.submissionsToGrade).toEqual([])
|
||||
})
|
||||
|
||||
it("counts toGrade from submitted (non-graded) submissions", () => {
|
||||
const submissions: HomeworkSubmissionListItem[] = [
|
||||
makeSubmission("1", "submitted"),
|
||||
makeSubmission("2", "submitted"),
|
||||
makeSubmission("3", "graded"),
|
||||
]
|
||||
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
|
||||
expect(metrics.toGradeCount).toBe(2)
|
||||
})
|
||||
|
||||
it("counts active assignments (published)", () => {
|
||||
const assignments: HomeworkAssignmentListItem[] = [
|
||||
makeAssignmentTeacher("1", "published"),
|
||||
makeAssignmentTeacher("2", "published"),
|
||||
makeAssignmentTeacher("3", "draft"),
|
||||
]
|
||||
const metrics = computeTeacherMetrics([], [], assignments, [], [], now)
|
||||
expect(metrics.activeAssignmentsCount).toBe(2)
|
||||
})
|
||||
|
||||
it("computes averageScore from gradeTrends", () => {
|
||||
const trends: TeacherGradeTrendItem[] = [
|
||||
makeTrend("1", 80),
|
||||
makeTrend("2", 90),
|
||||
]
|
||||
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||
expect(metrics.averageScore).toBe(85)
|
||||
})
|
||||
|
||||
it("computes submissionRate as percentage", () => {
|
||||
const trends: TeacherGradeTrendItem[] = [
|
||||
makeTrendWithSubmissions("1", 8, 10),
|
||||
makeTrendWithSubmissions("2", 6, 10),
|
||||
]
|
||||
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||
// (8 + 6) / (10 + 10) * 100 = 70
|
||||
expect(metrics.submissionRate).toBe(70)
|
||||
})
|
||||
|
||||
it("returns submissionRate 0 when no students", () => {
|
||||
const trends: TeacherGradeTrendItem[] = [
|
||||
makeTrendWithSubmissions("1", 0, 0),
|
||||
]
|
||||
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||
expect(metrics.submissionRate).toBe(0)
|
||||
})
|
||||
|
||||
it("filters today schedule by current weekday", () => {
|
||||
const classes: TeacherClass[] = [
|
||||
makeClass("c1", "Math 101"),
|
||||
]
|
||||
const schedule: ClassScheduleItem[] = [
|
||||
makeScheduleItem("s1", 1, "08:00"), // Monday
|
||||
makeScheduleItem("s2", 2, "09:00"), // Tuesday
|
||||
]
|
||||
const metrics = computeTeacherMetrics(classes, schedule, [], [], [], now)
|
||||
expect(metrics.todayScheduleItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("limits submissionsToGrade to 6 items", () => {
|
||||
const submissions: HomeworkSubmissionListItem[] = Array.from({ length: 10 }, (_, i) =>
|
||||
makeSubmission(`${i}`, "submitted"),
|
||||
)
|
||||
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
|
||||
expect(metrics.submissionsToGrade).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
function makeAssignment(
|
||||
id: string,
|
||||
progressStatus: string,
|
||||
dueAt: string | null,
|
||||
): StudentHomeworkAssignmentListItem {
|
||||
return {
|
||||
id,
|
||||
title: `Assignment ${id}`,
|
||||
subjectName: null,
|
||||
dueAt,
|
||||
availableAt: null,
|
||||
maxAttempts: 1,
|
||||
attemptsUsed: 0,
|
||||
progressStatus: progressStatus as StudentHomeworkAssignmentListItem["progressStatus"],
|
||||
latestSubmissionId: null,
|
||||
latestSubmittedAt: null,
|
||||
latestScore: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeScheduleItem(
|
||||
id: string,
|
||||
weekday: number,
|
||||
startTime: string,
|
||||
): ClassScheduleItem {
|
||||
return {
|
||||
id,
|
||||
classId: `class-${id}`,
|
||||
weekday: weekday as ClassScheduleItem["weekday"],
|
||||
startTime,
|
||||
endTime: "09:00",
|
||||
course: `Course ${id}`,
|
||||
location: null,
|
||||
} as ClassScheduleItem
|
||||
}
|
||||
|
||||
function makeSubmission(
|
||||
id: string,
|
||||
status: string,
|
||||
): HomeworkSubmissionListItem {
|
||||
return {
|
||||
id,
|
||||
assignmentId: `assignment-${id}`,
|
||||
assignmentTitle: `Assignment ${id}`,
|
||||
studentName: `Student ${id}`,
|
||||
status: status as HomeworkSubmissionListItem["status"],
|
||||
submittedAt: "2026-06-22T10:00:00",
|
||||
isLate: false,
|
||||
score: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssignmentTeacher(
|
||||
id: string,
|
||||
status: string,
|
||||
): HomeworkAssignmentListItem {
|
||||
return {
|
||||
id,
|
||||
sourceExamId: null,
|
||||
sourceExamTitle: null,
|
||||
title: `Assignment ${id}`,
|
||||
status: status as HomeworkAssignmentListItem["status"],
|
||||
availableAt: null,
|
||||
dueAt: null,
|
||||
allowLate: false,
|
||||
lateDueAt: null,
|
||||
maxAttempts: 1,
|
||||
createdAt: "2026-06-01T00:00:00",
|
||||
updatedAt: "2026-06-01T00:00:00",
|
||||
targetCount: 0,
|
||||
submittedCount: 0,
|
||||
gradedCount: 0,
|
||||
averageScore: null,
|
||||
overdueCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTrend(id: string, averageScore: number): TeacherGradeTrendItem {
|
||||
return {
|
||||
id,
|
||||
title: `Trend ${id}`,
|
||||
averageScore,
|
||||
maxScore: 100,
|
||||
submissionCount: 10,
|
||||
totalStudents: 10,
|
||||
createdAt: "2026-06-01T00:00:00",
|
||||
}
|
||||
}
|
||||
|
||||
function makeTrendWithSubmissions(
|
||||
id: string,
|
||||
submissionCount: number,
|
||||
totalStudents: number,
|
||||
): TeacherGradeTrendItem {
|
||||
return {
|
||||
id,
|
||||
title: `Trend ${id}`,
|
||||
averageScore: 80,
|
||||
maxScore: 100,
|
||||
submissionCount,
|
||||
totalStudents,
|
||||
createdAt: "2026-06-01T00:00:00",
|
||||
}
|
||||
}
|
||||
|
||||
function makeClass(id: string, name: string): TeacherClass {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
grade: "G1",
|
||||
homeroom: null,
|
||||
room: null,
|
||||
studentCount: 0,
|
||||
} as TeacherClass
|
||||
}
|
||||
Reference in New Issue
Block a user