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 ExperimentGradesControllerTests { [Fact] public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot() { await using var fixture = await ExperimentGradeFixture.CreateAsync(); fixture.Db.ExperimentProjects.Add(new ExperimentProject { TeachingTaskId = fixture.Task.Id, Code = "LAB-C", Name = "集中实验", ArrangementMode = ExperimentArrangementMode.Centralized, StartDate = fixture.Term.StartDate, EndDate = fixture.Term.EndDate, Status = ExperimentProjectStatus.Published, PublishedAt = DateTime.UtcNow }); await fixture.Db.SaveChangesAsync(); var project = await fixture.Db.ExperimentProjects.SingleAsync(); var experimentGrades = fixture.ExperimentGrades(fixture.AdminScope); var created = await experimentGrades.CreateSheet( new ExperimentGradeSheetRequest( project.Id, 1, 60, [ new ExperimentGradeItemRequest( "操作", ExperimentGradeItemKind.Operation, 50), new ExperimentGradeItemRequest( "报告", ExperimentGradeItemKind.Report, 50) ]), CancellationToken.None); Assert.IsType(created); var sheet = await fixture.Db.ExperimentGradeSheets .Include(x => x.Items) .Include(x => x.Records) .ThenInclude(x => x.ItemScores) .SingleAsync(); var record = Assert.Single(sheet.Records); var operation = sheet.Items.Single(x => x.Name == "操作"); var report = sheet.Items.Single(x => x.Name == "报告"); Assert.IsType( await experimentGrades.UpdateRecords( sheet.Id, new ExperimentGradeRecordsRequest( [ new ExperimentGradeRecordRequest( record.Id, ExperimentParticipationStatus.Completed, false, 1, "https://example.test/report", DateTime.UtcNow, false, "完成规范", [ new ExperimentGradeItemScoreRequest( operation.Id, 80, "操作正确"), new ExperimentGradeItemScoreRequest( report.Id, 90, "报告完整") ]) ]), CancellationToken.None)); Assert.IsType( await experimentGrades.Submit( sheet.Id, CancellationToken.None)); Assert.IsType( await experimentGrades.Approve( sheet.Id, CancellationToken.None)); Assert.IsType( await experimentGrades.Publish( sheet.Id, CancellationToken.None)); fixture.Db.ChangeTracker.Clear(); var publishedRecord = await fixture.Db.ExperimentGradeRecords .SingleAsync(); Assert.Equal(85m, publishedRecord.TotalScore); Assert.True(publishedRecord.IsPassed); Assert.Equal( ExperimentGradeSheetStatus.Published, (await fixture.Db.ExperimentGradeSheets.SingleAsync()).Status); var studentResult = await fixture .ExperimentGrades(fixture.StudentScope) .GetMine(CancellationToken.None); Assert.IsType(studentResult); var courseSheet = new GradeSheet { TeachingTaskId = fixture.Task.Id, RegularWeight = 0, FinalWeight = 50, Items = [ new GradeItem { Name = "实验", Weight = 50, SortOrder = 0 } ], Records = [ new GradeRecord { StudentId = fixture.Student.Id, FinalScore = 80 } ] }; var courseItem = courseSheet.Items.Single(); courseSheet.Records.Single().ItemScores.Add(new GradeItemScore { GradeItemId = courseItem.Id }); fixture.Db.GradeSheets.Add(courseSheet); await fixture.Db.SaveChangesAsync(); var grades = new GradesController(fixture.Db, fixture.AdminScope); Assert.IsType( await grades.ImportExperimentScores( courseSheet.Id, new ImportExperimentScoresRequest(courseItem.Id), CancellationToken.None)); fixture.Db.ChangeTracker.Clear(); var importedItem = await fixture.Db.GradeItems.SingleAsync(); var importedRecord = await fixture.Db.GradeRecords .Include(x => x.ItemScores) .SingleAsync(); Assert.Equal( GradeItemSourceType.ExperimentSummary, importedItem.SourceType); Assert.NotNull(importedItem.SourceSnapshotAt); Assert.Equal(85m, importedRecord.ItemScores.Single().Score); Assert.Equal(82.5m, importedRecord.TotalScore); var manualOverride = await grades.UpdateRecords( courseSheet.Id, new GradeRecordsRequest( [ new GradeRecordRequest( importedRecord.Id, null, 80, [new GradeItemScoreRequest(importedItem.Id, 90)], GradeExamStatus.Normal, null) ]), CancellationToken.None); Assert.IsType(manualOverride); } [Fact] public async Task SelfScheduledSheet_SyncsOnlyActiveBookings() { await using var fixture = await ExperimentGradeFixture.CreateAsync(); var project = new ExperimentProject { TeachingTaskId = fixture.Task.Id, Code = "LAB-S", Name = "自主实验", ArrangementMode = ExperimentArrangementMode.SelfScheduled, StartDate = fixture.Term.StartDate, EndDate = fixture.Term.EndDate, Status = ExperimentProjectStatus.Published, PublishedAt = DateTime.UtcNow }; var session = new ExperimentSession { ExperimentProjectId = project.Id, ClassroomId = fixture.Classroom.Id, SessionDate = fixture.Term.StartDate, StartPeriod = 1, PeriodCount = 2, Capacity = 10, ReservedCount = 1 }; var booking = new ExperimentBooking { ExperimentProjectId = project.Id, ExperimentSessionId = session.Id, StudentId = fixture.Student.Id, Status = ExperimentBookingStatus.Booked, BookedAt = DateTime.UtcNow }; fixture.Db.AddRange(project, session, booking); await fixture.Db.SaveChangesAsync(); var controller = fixture.ExperimentGrades(fixture.AdminScope); await controller.CreateSheet( new ExperimentGradeSheetRequest( project.Id, 1, 60, [ new ExperimentGradeItemRequest( "操作", ExperimentGradeItemKind.Operation, 100) ]), CancellationToken.None); var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync(); Assert.Single(fixture.Db.ExperimentGradeRecords); booking.Status = ExperimentBookingStatus.Cancelled; booking.CancelledAt = DateTime.UtcNow; await fixture.Db.SaveChangesAsync(); Assert.IsType( await controller.SyncParticipants( sheet.Id, CancellationToken.None)); Assert.Empty(fixture.Db.ExperimentGradeRecords); fixture.Db.ChangeTracker.Clear(); booking = await fixture.Db.ExperimentBookings.SingleAsync(); controller = fixture.ExperimentGrades(fixture.AdminScope); booking.Status = ExperimentBookingStatus.Booked; booking.CancelledAt = null; await fixture.Db.SaveChangesAsync(); Assert.IsType( await controller.SyncParticipants( sheet.Id, CancellationToken.None)); Assert.Single(fixture.Db.ExperimentGradeRecords); } private sealed class ExperimentGradeFixture : IAsyncDisposable { private ExperimentGradeFixture( SqliteConnection connection, AppDbContext db, AcademicTerm term, TeachingTask task, Student student, Classroom classroom, ICurrentUserDataScope adminScope, ICurrentUserDataScope studentScope) { Connection = connection; Db = db; Term = term; Task = task; Student = student; Classroom = classroom; AdminScope = adminScope; StudentScope = studentScope; } private SqliteConnection Connection { get; } public AppDbContext Db { get; } public AcademicTerm Term { get; } public TeachingTask Task { get; } public Student Student { get; } public Classroom Classroom { get; } public ICurrentUserDataScope AdminScope { get; } public ICurrentUserDataScope StudentScope { get; } public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var db = new AppDbContext( new DbContextOptionsBuilder() .UseSqlite(connection) .Options); await db.Database.EnsureCreatedAsync(); var admin = User("lab-admin", "实验管理员"); var studentUser = User("lab-student", "实验学生"); var teacherUser = User("lab-teacher", "实验教师"); var campus = new Campus { Code = "MAIN", Name = "主校区" }; var building = new Building { CampusId = campus.Id, Code = "LAB", Name = "实验楼" }; var classroom = new Classroom { BuildingId = building.Id, Code = "LAB201", Name = "实验室 201", Capacity = 30 }; var college = new College { Code = "CS", Name = "计算机学院" }; var major = new Major { CollegeId = college.Id, Code = "CS", Name = "计算机科学与技术", DegreeType = "工学" }; var administrativeClass = new AdministrativeClass { MajorId = major.Id, Code = "CS2099", Name = "计科 2099", Grade = 2099 }; var student = new Student { UserId = studentUser.Id, AdministrativeClassId = administrativeClass.Id, StudentNumber = "20990001", Name = "实验学生", EnrollmentYear = 2099, EnrollmentDate = new DateOnly(2099, 9, 1) }; var teacher = new Teacher { UserId = teacherUser.Id, CollegeId = college.Id, TeacherNumber = "T2099", Name = "实验教师" }; var term = new AcademicTerm { Code = "2099-1", Name = "2099—2100 学年第一学期", AcademicYear = "2099-2100", Season = TermSeason.Autumn, StartDate = new DateOnly(2099, 9, 7), EndDate = new DateOnly(2100, 1, 17), IsCurrent = true }; var course = new Course { CollegeId = college.Id, Code = "CSLAB", Name = "系统实验", Credits = 2, TotalHours = 32, LectureHours = 16, PracticeHours = 16, Nature = CourseNature.Practice, AssessmentMethod = AssessmentMethod.Assessment }; var task = new TeachingTask { AcademicTermId = term.Id, CourseId = course.Id, TaskNumber = "2099-1-CSLAB-01", Name = "系统实验教学班", Capacity = 30, Status = TeachingTaskStatus.Published, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ], Classes = [ new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id } ] }; db.AddRange( admin, studentUser, teacherUser, campus, building, classroom, college, major, administrativeClass, student, teacher, term, course, task); await db.SaveChangesAsync(); return new ExperimentGradeFixture( connection, db, term, task, student, classroom, Scope(admin, SystemRoles.SuperAdmin, DataScope.All), Scope(studentUser, SystemRoles.Student, DataScope.Self)); } public ExperimentGradesController ExperimentGrades( ICurrentUserDataScope scope) => new(Db, scope); public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await Connection.DisposeAsync(); } private static ApplicationUser User( string userName, string displayName) => new() { Id = Guid.NewGuid(), UserName = userName, NormalizedUserName = userName.ToUpperInvariant(), DisplayName = displayName, IsEnabled = true }; private static ICurrentUserDataScope Scope( ApplicationUser user, string role, DataScope dataScope) => new FixedScope(new CurrentUserScope( user.Id, user.DisplayName, user.CollegeId, dataScope, new HashSet([role]))); } private sealed class FixedScope(CurrentUserScope current) : ICurrentUserDataScope { public CurrentUserScope Current { get; } = current; } }