using ClosedXML.Excel; using Jiaowu.Api.Contracts; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Tests; public sealed class GradesPaginationTests { [Fact] public async Task GradeTasksAndStudentRecords_AreReturnedByPage() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = new AppDbContext( new DbContextOptionsBuilder() .UseSqlite(connection) .Options); await db.Database.EnsureCreatedAsync(); var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { Code = "080901", Name = "计算机科学与技术", CollegeId = college.Id, DegreeType = "工学学士" }; var administrativeClass = new AdministrativeClass { Code = "CS2026-01", Name = "计科 2026-1 班", MajorId = major.Id, Grade = 2026 }; var course = new Course { Code = "CS101", Name = "程序设计基础", CollegeId = college.Id, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination }; var term = new AcademicTerm { Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 20) }; var tasks = Enumerable.Range(1, 11) .Select(number => new TeachingTask { TaskNumber = $"2026-1-CS101-{number:00}", Name = $"程序设计基础教学班 {number}", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Published }) .ToList(); var students = Enumerable.Range(1, 11) .Select(number => new Student { StudentNumber = $"202601{number:000}", Name = $"学生 {number}", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }) .ToList(); var sheet = new GradeSheet { TeachingTaskId = tasks[0].Id, RegularWeight = 20, FinalWeight = 60, Items = [ new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 } ], Records = students.Select(student => new GradeRecord { StudentId = student.Id }).ToList() }; db.AddRange(college, major, administrativeClass, course, term); db.AddRange(tasks); db.AddRange(students); db.Add(sheet); await db.SaveChangesAsync(); var controller = new GradesController(db, new AllScope()); var listResult = await controller.GetSheets( term.Id, null, null, 1, 10, CancellationToken.None); var listOk = Assert.IsType(listResult); var page = Assert.IsType>(listOk.Value); Assert.Equal(11, page.Total); Assert.Equal(10, page.Items.Count); var firstDetail = Assert.IsType(await controller.GetSheet( sheet.Id, 1, 10, null, CancellationToken.None)); var firstSheet = firstDetail.Value!.GetType().GetProperty("Sheet")! .GetValue(firstDetail.Value)!; Assert.Equal(11, ReadInt(firstSheet, "RecordTotal")); Assert.Equal(10, ReadItems(firstSheet, "Records").Count); var secondDetail = Assert.IsType(await controller.GetSheet( sheet.Id, 2, 10, null, CancellationToken.None)); var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")! .GetValue(secondDetail.Value)!; Assert.Single(ReadItems(secondSheet, "Records")); var templateResult = Assert.IsType( await controller.DownloadTemplate(sheet.Id, CancellationToken.None)); using var stream = new MemoryStream(templateResult.FileContents); using var workbook = new XLWorkbook(stream); var worksheet = workbook.Worksheet("成绩导入"); Assert.Equal( ["学号", "姓名", "班级", "平时成绩", "实验", "期末成绩", "总分(自动计算)", "考试状态", "备注"], worksheet.Row(1).CellsUsed().Select(cell => cell.GetString())); Assert.Contains("D2*20/100", worksheet.Cell("G2").FormulaA1); Assert.Contains("E2*20/100", worksheet.Cell("G2").FormulaA1); Assert.Contains("F2*60/100", worksheet.Cell("G2").FormulaA1); } [Fact] public async Task SubmittingCourseGradeSheet_LoadsCourseForNotificationAndReturnsSuccess() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = new AppDbContext( new DbContextOptionsBuilder() .UseSqlite(connection) .Options); await db.Database.EnsureCreatedAsync(); var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { Code = "080901", Name = "计算机科学与技术", CollegeId = college.Id, DegreeType = "工学学士" }; var administrativeClass = new AdministrativeClass { Code = "CS2026-01", Name = "计科 2026-1 班", MajorId = major.Id, Grade = 2026 }; var term = new AcademicTerm { Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 20) }; var course = new Course { Code = "CS101", Name = "程序设计基础", CollegeId = college.Id, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination }; var task = new TeachingTask { TaskNumber = "2026-1-CS101-01", Name = "程序设计基础教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Published }; var student = new Student { StudentNumber = "202601001", Name = "学生 1", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }; var sheet = new GradeSheet { TeachingTaskId = task.Id, RegularWeight = 40, FinalWeight = 60, Records = [ new GradeRecord { StudentId = student.Id, RegularScore = 80, FinalScore = 90 } ] }; db.AddRange(college, major, administrativeClass, term, course, task, student, sheet); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); var result = await new GradesController(db, new AllScope()).Submit( sheet.Id, CancellationToken.None); Assert.IsType(result); Assert.Equal( GradeSheetStatus.Submitted, await db.GradeSheets.Where(x => x.Id == sheet.Id) .Select(x => x.Status) .SingleAsync()); var approvedSheet = await db.GradeSheets.SingleAsync(x => x.Id == sheet.Id); approvedSheet.Status = GradeSheetStatus.Approved; await db.SaveChangesAsync(); var publishResult = await new GradesController(db, new AllScope()).Publish( sheet.Id, CancellationToken.None); Assert.IsType(publishResult); Assert.Empty(await db.CourseGradeStatisticsRefreshJobs.ToListAsync()); Assert.Empty(await db.BackgroundJobOutboxMessages.ToListAsync()); } [Fact] public async Task StudentAcademicOverview_UsesAllPublishedGradesAndRanksPeers() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); await using var db = new AppDbContext( new DbContextOptionsBuilder().UseSqlite(connection).Options); await db.Database.EnsureCreatedAsync(); var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { Code = "080901", Name = "计算机科学与技术", CollegeId = college.Id, DegreeType = "工学学士" }; var administrativeClass = new AdministrativeClass { Code = "CS2026-01", Name = "计科 2026-1 班", MajorId = major.Id, Grade = 2026 }; var course = new Course { Code = "CS101", Name = "程序设计基础", CollegeId = college.Id, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16, Nature = CourseNature.MajorRequired, AssessmentMethod = AssessmentMethod.Examination }; var term = new AcademicTerm { Code = "2026-1", Name = "2026—2027 学年第一学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 20) }; var userId = Guid.NewGuid(); var user = new ApplicationUser { Id = userId, UserName = "202601001", DisplayName = "学生 1" }; var student = new Student { StudentNumber = "202601001", Name = "学生 1", UserId = userId, AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }; var peer = new Student { StudentNumber = "202601002", Name = "学生 2", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }; var task = new TeachingTask { TaskNumber = "2026-1-CS101-01", Name = "程序设计基础教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Closed }; var sheet = new GradeSheet { TeachingTaskId = task.Id, Status = GradeSheetStatus.Published, RegularWeight = 40, FinalWeight = 60, Records = [ new GradeRecord { StudentId = student.Id, TotalScore = 80, GradePoint = 3.0m }, new GradeRecord { StudentId = peer.Id, TotalScore = 90, GradePoint = 4.0m } ] }; db.AddRange(college, major, administrativeClass, course, term, user, student, peer, task, sheet); await db.SaveChangesAsync(); await new StudentAcademicStatisticsService(db, NoOpAppCache.Instance) .RefreshForGradeSheetAsync(sheet.Id, CancellationToken.None); var result = Assert.IsType(await new GradesController( db, new StudentScope(userId)).GetStudentAcademicOverview(CancellationToken.None)); var metrics = result.Value!.GetType().GetProperty("Metrics")!.GetValue(result.Value)!; var gpa = metrics.GetType().GetProperty("Gpa")!.GetValue(metrics)!; var classRank = gpa.GetType().GetProperty("ClassRank")!.GetValue(gpa)!; var trend = ((System.Collections.IEnumerable)result.Value.GetType() .GetProperty("TermTrend")!.GetValue(result.Value)!).Cast().ToList(); Assert.Equal(3.0m, gpa.GetType().GetProperty("Value")!.GetValue(gpa)); Assert.Equal(2, classRank.GetType().GetProperty("Rank")!.GetValue(classRank)); Assert.Equal(2, classRank.GetType().GetProperty("PeerCount")!.GetValue(classRank)); var firstTerm = Assert.Single(trend); Assert.Equal("2026—2027 学年第一学期", firstTerm.GetType().GetProperty("TermName")!.GetValue(firstTerm)); Assert.Equal(3.0m, firstTerm.GetType().GetProperty("Gpa")!.GetValue(firstTerm)); } private static int ReadInt(object value, string property) => (int)value.GetType().GetProperty(property)!.GetValue(value)!; private static List ReadItems(object value, string property) => ((System.Collections.IEnumerable)value.GetType().GetProperty(property)! .GetValue(value)!).Cast().ToList(); private sealed class AllScope : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( Guid.NewGuid(), "测试管理员", null, DataScope.All, new HashSet([SystemRoles.SuperAdmin])); } private sealed class StudentScope(Guid userId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( userId, "测试学生", null, DataScope.Self, new HashSet([SystemRoles.Student])); } }