using ClosedXML.Excel; using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Tests; public sealed class AttendanceControllerTests { [Fact] public async Task TaskStatistics_UsesSubmittedSheetsAndExportsAllDetails() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; await using var db = new AppDbContext(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 studentUserId = Guid.NewGuid(); var studentUser = new ApplicationUser { Id = studentUserId, UserName = "202601001", NormalizedUserName = "202601001", DisplayName = "周同学" }; var firstStudent = new Student { StudentNumber = "202601001", Name = "周同学", UserId = studentUserId, AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }; var secondStudentUserId = Guid.NewGuid(); var secondStudentUser = new ApplicationUser { Id = secondStudentUserId, UserName = "202601002", NormalizedUserName = "202601002", DisplayName = "吴同学" }; var secondStudent = new Student { StudentNumber = "202601002", Name = "吴同学", UserId = secondStudentUserId, AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2026, EnrollmentDate = new DateOnly(2026, 9, 1) }; 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 task = new TeachingTask { TaskNumber = "2026-1-CS101-01", Name = "程序设计基础教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, Status = TeachingTaskStatus.Published }; db.AddRange( studentUser, secondStudentUser, college, major, administrativeClass, firstStudent, secondStudent, course, term, task); await db.SaveChangesAsync(); db.AttendanceSheets.AddRange( new AttendanceSheet { TeachingTaskId = task.Id, Name = "第1周点名", AttendanceDate = new DateTime(2026, 9, 3), Status = AttendanceSheetStatus.Submitted, Records = [ new AttendanceRecord { StudentId = firstStudent.Id, Status = AttendanceStatus.Present }, new AttendanceRecord { StudentId = secondStudent.Id, Status = AttendanceStatus.Absent } ] }, new AttendanceSheet { TeachingTaskId = task.Id, Name = "第2周点名", AttendanceDate = new DateTime(2026, 9, 10), Status = AttendanceSheetStatus.Submitted, Records = [ new AttendanceRecord { StudentId = firstStudent.Id, Status = AttendanceStatus.Late }, new AttendanceRecord { StudentId = secondStudent.Id, Status = AttendanceStatus.Excused } ] }, new AttendanceSheet { TeachingTaskId = task.Id, Name = "未提交点名", AttendanceDate = new DateTime(2026, 9, 17), Status = AttendanceSheetStatus.Draft, Records = [ new AttendanceRecord { StudentId = firstStudent.Id, Status = AttendanceStatus.Absent } ] }); await db.SaveChangesAsync(); var controller = new AttendanceController(db, new AllDataScope()); var result = await controller.GetTaskStatistics( task.Id, CancellationToken.None); var ok = Assert.IsType(result); var statistics = Assert.IsType(ok.Value); Assert.Equal(2, statistics.Summary.SubmittedSheetCount); Assert.Equal(4, statistics.Summary.RecordCount); Assert.Equal(3, statistics.Summary.RequiredCount); Assert.Equal(66.7m, statistics.Summary.OverallAttendanceRate); Assert.Equal(0, statistics.Summary.PerfectAttendanceCount); Assert.Equal(2, statistics.Students.Count); Assert.Equal(0m, statistics.Students[0].AttendanceRate); Assert.Equal(100m, statistics.Students[1].AttendanceRate); var exportResult = await controller.ExportTaskStatistics( task.Id, CancellationToken.None); var file = Assert.IsType(exportResult); using var workbook = new XLWorkbook(new MemoryStream(file.FileContents)); Assert.NotNull(workbook.Worksheet("课程概览")); Assert.Equal( 3, workbook.Worksheet("学生出勤明细").LastRowUsed()!.RowNumber()); Assert.Equal( 3, workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber()); var now = DateTime.UtcNow; var qrRecord = new AttendanceRecord { StudentId = firstStudent.Id, Status = AttendanceStatus.Absent }; var secondQrRecord = new AttendanceRecord { StudentId = secondStudent.Id, Status = AttendanceStatus.Absent }; var qrSheet = new AttendanceSheet { TeachingTaskId = task.Id, Name = "课堂扫码签到", AttendanceDate = now, Status = AttendanceSheetStatus.Draft, CheckInMethod = AttendanceCheckInMethod.QrCode, CheckInToken = "TEST-QR-TOKEN", CheckInStartsAt = now.AddMinutes(-1), CheckInEndsAt = now.AddMinutes(10), Records = [qrRecord, secondQrRecord] }; db.AttendanceSheets.Add(qrSheet); await db.SaveChangesAsync(); var qrChallengeResult = await controller.GetQrChallenge( qrSheet.Id, CancellationToken.None); var qrChallengeOk = Assert.IsType(qrChallengeResult); var qrToken = Assert.IsType( qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue( qrChallengeOk.Value)); var studentController = new AttendanceController( db, new StudentDataScope(studentUserId)); var myRecordsResult = await studentController.GetMyRecords( null, CancellationToken.None); var myRecordsOk = Assert.IsType(myRecordsResult); var myRecords = Assert .IsAssignableFrom(myRecordsOk.Value) .Cast() .ToList(); Assert.Equal(2, myRecords.Count); Assert.All(myRecords, item => Assert.Equal( task.Id, item.GetType().GetProperty("TeachingTaskId")!.GetValue(item))); var infoResult = await studentController.GetCheckInInfo( qrToken, CancellationToken.None); Assert.IsType(infoResult); Assert.IsType( await studentController.GetCheckInInfo( qrSheet.CheckInToken, CancellationToken.None)); var qrCheckInResult = await studentController.CheckIn( new AttendanceCheckInRequest( null, qrToken, null, null, null, "test-device-1", "android"), CancellationToken.None); Assert.IsType(qrCheckInResult); Assert.Equal(AttendanceStatus.Present, qrRecord.Status); Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod); Assert.NotNull(qrRecord.CheckInAt); Assert.Null(qrRecord.CheckInLatitude); var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync( x => x.AttendanceSheetId == qrSheet.Id); Assert.True(qrAttempt.IsSuccessful); Assert.Equal("android", qrAttempt.DevicePlatform); Assert.NotNull(qrAttempt.DeviceIdentifierHash); var secondStudentController = new AttendanceController( db, new StudentDataScope(secondStudentUserId)); Assert.IsType( await secondStudentController.CheckIn( new AttendanceCheckInRequest( null, qrToken, null, null, null, "test-device-1", "android"), CancellationToken.None)); var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync( x => x.AttendanceSheetId == qrSheet.Id && x.StudentId == secondStudent.Id); Assert.Contains( "SharedDevice", sharedDeviceAttempt.RiskFlags ?? string.Empty); var sheetDetailResult = await controller.GetSheet( qrSheet.Id, CancellationToken.None); var sheetDetailOk = Assert.IsType(sheetDetailResult); var sheetDetail = sheetDetailOk.Value!.GetType() .GetProperty("Sheet")! .GetValue(sheetDetailOk.Value)!; var riskSummary = sheetDetail.GetType() .GetProperty("RiskSummary")! .GetValue(sheetDetail)!; Assert.Equal( 2, riskSummary.GetType() .GetProperty("SharedDeviceStudentCount")! .GetValue(riskSummary)); var locationRecord = new AttendanceRecord { StudentId = firstStudent.Id, Status = AttendanceStatus.Absent }; var locationSheet = new AttendanceSheet { TeachingTaskId = task.Id, Name = "课堂定位签到", AttendanceDate = now, Status = AttendanceSheetStatus.Draft, CheckInMethod = AttendanceCheckInMethod.Location, CheckInStartsAt = now.AddMinutes(-1), CheckInEndsAt = now.AddMinutes(10), TargetLatitude = 39.9m, TargetLongitude = 116.4m, LocationRadiusMeters = 100, Records = [locationRecord] }; db.AttendanceSheets.Add(locationSheet); await db.SaveChangesAsync(); var outsideResult = await studentController.CheckIn( new AttendanceCheckInRequest( locationSheet.Id, null, 39.91m, 116.4m, 8), CancellationToken.None); Assert.IsType(outsideResult); Assert.Null(locationRecord.CheckInAt); var inaccurateResult = await studentController.CheckIn( new AttendanceCheckInRequest( locationSheet.Id, null, 39.9001m, 116.4m, 150), CancellationToken.None); Assert.IsType(inaccurateResult); Assert.Null(locationRecord.CheckInAt); var missingAccuracyResult = await studentController.CheckIn( new AttendanceCheckInRequest( locationSheet.Id, null, 39.9001m, 116.4m, null), CancellationToken.None); Assert.IsType(missingAccuracyResult); Assert.Null(locationRecord.CheckInAt); var locationFailures = await db.AttendanceCheckInAttempts .Where(x => x.AttendanceSheetId == locationSheet.Id && !x.IsSuccessful) .OrderBy(x => x.CreatedAt) .ToListAsync(); Assert.Equal(3, locationFailures.Count); Assert.Contains( locationFailures, x => (x.RiskFlags ?? string.Empty).Contains( "RepeatedFailures", StringComparison.Ordinal)); var nearbyResult = await studentController.CheckIn( new AttendanceCheckInRequest( locationSheet.Id, null, 39.9001m, 116.4m, 8), CancellationToken.None); Assert.IsType(nearbyResult); Assert.Equal(AttendanceStatus.Present, locationRecord.Status); Assert.Equal( AttendanceCheckInMethod.Location, locationRecord.CheckedInMethod); Assert.InRange(locationRecord.CheckInDistanceMeters!.Value, 1, 100); } private sealed class AllDataScope : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( Guid.NewGuid(), "测试管理员", null, DataScope.All, new HashSet([SystemRoles.SuperAdmin])); } private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = new( userId, "测试学生", null, DataScope.Self, new HashSet([SystemRoles.Student])); } }