409 lines
14 KiB
C#
409 lines
14 KiB
C#
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<AppDbContext>()
|
|
.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<OkObjectResult>(result);
|
|
var statistics = Assert.IsType<AttendanceCourseStatistics>(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<FileContentResult>(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<OkObjectResult>(qrChallengeResult);
|
|
var qrToken = Assert.IsType<string>(
|
|
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<OkObjectResult>(myRecordsResult);
|
|
var myRecords = Assert
|
|
.IsAssignableFrom<System.Collections.IEnumerable>(myRecordsOk.Value)
|
|
.Cast<object>()
|
|
.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<OkObjectResult>(infoResult);
|
|
Assert.IsType<NotFoundResult>(
|
|
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<OkObjectResult>(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<OkObjectResult>(
|
|
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 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<ConflictObjectResult>(outsideResult);
|
|
Assert.Null(locationRecord.CheckInAt);
|
|
|
|
var inaccurateResult = await studentController.CheckIn(
|
|
new AttendanceCheckInRequest(
|
|
locationSheet.Id,
|
|
null,
|
|
39.9001m,
|
|
116.4m,
|
|
150),
|
|
CancellationToken.None);
|
|
Assert.IsType<ConflictObjectResult>(inaccurateResult);
|
|
Assert.Null(locationRecord.CheckInAt);
|
|
|
|
var missingAccuracyResult = await studentController.CheckIn(
|
|
new AttendanceCheckInRequest(
|
|
locationSheet.Id,
|
|
null,
|
|
39.9001m,
|
|
116.4m,
|
|
null),
|
|
CancellationToken.None);
|
|
Assert.IsType<ConflictObjectResult>(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<OkObjectResult>(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<string>([SystemRoles.SuperAdmin]));
|
|
}
|
|
|
|
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
|
{
|
|
public CurrentUserScope Current { get; } = new(
|
|
userId,
|
|
"测试学生",
|
|
null,
|
|
DataScope.Self,
|
|
new HashSet<string>([SystemRoles.Student]));
|
|
}
|
|
}
|