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
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
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
|
|
}
|