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 Jiaowu.Api.Infrastructure.Timetables; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Tests; public sealed class ExperimentsControllerTests { [Fact] public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks() { await using var fixture = await ExperimentFixture.CreateAsync(); var controller = fixture.Controller(fixture.ManagerScope); var result = await controller.CreateProjects( fixture.BatchProjectRequest( ExperimentArrangementMode.Centralized), CancellationToken.None); Assert.IsType(result); var projects = await fixture.Db.ExperimentProjects .OrderBy(x => x.TeachingTaskId) .ToListAsync(); Assert.Equal(2, projects.Count); Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count()); Assert.All(projects, project => { Assert.Equal("LAB-BATCH", project.Code); Assert.Equal("公共实验任务", project.Name); Assert.Equal(ExperimentProjectStatus.Draft, project.Status); }); } [Fact] public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts() { await using var fixture = await ExperimentFixture.CreateAsync(); var controller = fixture.Controller(fixture.ManagerScope); await controller.CreateProjects( fixture.BatchProjectRequest( ExperimentArrangementMode.SelfScheduled), CancellationToken.None); var projectIds = await fixture.Db.ExperimentProjects .OrderBy(x => x.TeachingTaskId) .Select(x => x.Id) .ToListAsync(); var result = await controller.CreateSessions( new ExperimentSessionBatchRequest( [ new ExperimentSessionBatchItem( projectIds[0], fixture.Classroom.Id, fixture.Term.StartDate, 1, 2, 20, null), new ExperimentSessionBatchItem( projectIds[1], fixture.Classroom.Id, fixture.Term.StartDate, 1, 2, 20, null) ]), CancellationToken.None); Assert.IsType(result); fixture.Db.ChangeTracker.Clear(); Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync()); } [Fact] public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid() { await using var fixture = await ExperimentFixture.CreateAsync(); var controller = fixture.Controller(fixture.ManagerScope); await controller.CreateProjects( fixture.BatchProjectRequest( ExperimentArrangementMode.SelfScheduled), CancellationToken.None); var projectIds = await fixture.Db.ExperimentProjects .OrderBy(x => x.TeachingTaskId) .Select(x => x.Id) .ToListAsync(); var result = await controller.CreateSessions( new ExperimentSessionBatchRequest( [ new ExperimentSessionBatchItem( projectIds[0], fixture.Classroom.Id, fixture.Term.StartDate, 1, 2, 20, null), new ExperimentSessionBatchItem( projectIds[1], fixture.SecondClassroom.Id, fixture.Term.StartDate, 1, 2, 20, null) ]), CancellationToken.None); Assert.IsType(result); Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync()); } [Fact] public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster() { await using var fixture = await ExperimentFixture.CreateAsync(); var controller = fixture.Controller(fixture.ManagerScope); var created = await controller.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.Centralized), CancellationToken.None); Assert.IsType(created); var project = await fixture.Db.ExperimentProjects.SingleAsync(); var sessionResult = await controller.CreateSession( project.Id, fixture.SessionRequest(1, 2, 10), CancellationToken.None); Assert.IsType(sessionResult); Assert.Equal( 1, (await fixture.Db.ExperimentSessions.SingleAsync()).Capacity); var published = await controller.PublishProject( project.Id, CancellationToken.None); Assert.IsType(published); Assert.Equal( ExperimentProjectStatus.Published, (await fixture.Db.ExperimentProjects.SingleAsync()).Status); var session = await fixture.Db.ExperimentSessions.SingleAsync(); var participants = await controller.GetParticipants( session.Id, CancellationToken.None); var ok = Assert.IsType(participants); var rows = Assert.IsAssignableFrom>(ok.Value); Assert.Single(rows); } [Fact] public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged() { await using var fixture = await ExperimentFixture.CreateAsync(); var manager = fixture.Controller(fixture.ManagerScope); await manager.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), CancellationToken.None); var project = await fixture.Db.ExperimentProjects.SingleAsync(); await manager.CreateSession( project.Id, fixture.SessionRequest(3, 2), CancellationToken.None); await manager.CreateSession( project.Id, fixture.SessionRequest(5, 2), CancellationToken.None); await manager.PublishProject(project.Id, CancellationToken.None); var sessions = await fixture.Db.ExperimentSessions .OrderBy(x => x.StartPeriod) .ToListAsync(); var student = fixture.Controller(fixture.StudentScope); Assert.IsType( await student.Book(sessions[0].Id, CancellationToken.None)); Assert.IsType( await student.Book(sessions[1].Id, CancellationToken.None)); var booking = await fixture.Db.ExperimentBookings.SingleAsync(); Assert.IsType( await student.CancelBooking(booking.Id, CancellationToken.None)); Assert.IsType( await student.Book(sessions[1].Id, CancellationToken.None)); var active = await fixture.Db.ExperimentBookings.SingleAsync(); Assert.Equal(ExperimentBookingStatus.Booked, active.Status); Assert.Equal(sessions[1].Id, active.ExperimentSessionId); Assert.Equal(0, (await fixture.Db.ExperimentSessions.FindAsync( sessions[0].Id))!.ReservedCount); Assert.Equal(1, (await fixture.Db.ExperimentSessions.FindAsync( sessions[1].Id))!.ReservedCount); } [Fact] public async Task StudentBooking_RejectsPublishedTimetableConflict() { await using var fixture = await ExperimentFixture.CreateAsync(); var manager = fixture.Controller(fixture.ManagerScope); await manager.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), CancellationToken.None); var project = await fixture.Db.ExperimentProjects.SingleAsync(); await manager.CreateSession( project.Id, fixture.SessionRequest(1, 2), CancellationToken.None); await manager.PublishProject(project.Id, CancellationToken.None); fixture.Db.SchedulePlans.Add(new SchedulePlan { AcademicTermId = fixture.Term.Id, Name = "正式课表", Version = "V1", Status = SchedulePlanStatus.Published, Entries = [ new ScheduleEntry { TeachingTaskId = fixture.Task.Id, ClassroomId = fixture.SecondClassroom.Id, DayOfWeek = 1, StartPeriod = 1, PeriodCount = 2, StartWeek = 1, EndWeek = 18, WeekPattern = WeekPattern.All } ] }); await fixture.Db.SaveChangesAsync(); var session = await fixture.Db.ExperimentSessions.SingleAsync(); var result = await fixture.Controller(fixture.StudentScope) .Book(session.Id, CancellationToken.None); Assert.IsType(result); Assert.Empty(fixture.Db.ExperimentBookings); } [Fact] public async Task CentralizedSession_RejectsRelatedPublishedTimetableConflict() { await using var fixture = await ExperimentFixture.CreateAsync(); var manager = fixture.Controller(fixture.ManagerScope); await manager.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.Centralized), CancellationToken.None); var project = await fixture.Db.ExperimentProjects.SingleAsync(); fixture.Db.SchedulePlans.Add(new SchedulePlan { AcademicTermId = fixture.Term.Id, Name = "正式课表", Version = "V1", Status = SchedulePlanStatus.Published, Entries = [ new ScheduleEntry { TeachingTaskId = fixture.Task.Id, ClassroomId = fixture.SecondClassroom.Id, DayOfWeek = 1, StartPeriod = 1, PeriodCount = 2, StartWeek = 1, EndWeek = 18, WeekPattern = WeekPattern.All } ] }); await fixture.Db.SaveChangesAsync(); var result = await manager.CreateSession( project.Id, fixture.SessionRequest(1, 2), CancellationToken.None); Assert.IsType(result); Assert.Empty(fixture.Db.ExperimentSessions); } [Fact] public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions() { await using var fixture = await ExperimentFixture.CreateAsync(); var manager = fixture.Controller(fixture.ManagerScope); await manager.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.Centralized), CancellationToken.None); var centralized = await fixture.Db.ExperimentProjects.SingleAsync(); await manager.CreateSession( centralized.Id, fixture.SessionRequest(1, 2), CancellationToken.None); await manager.PublishProject(centralized.Id, CancellationToken.None); await manager.CreateProject( fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled), CancellationToken.None); var selfScheduled = await fixture.Db.ExperimentProjects.SingleAsync( x => x.ArrangementMode == ExperimentArrangementMode.SelfScheduled); await manager.CreateSession( selfScheduled.Id, fixture.SessionRequest(4, 2), CancellationToken.None); await manager.PublishProject(selfScheduled.Id, CancellationToken.None); var selfSession = await fixture.Db.ExperimentSessions.SingleAsync( x => x.ExperimentProjectId == selfScheduled.Id); var studentController = fixture.Controller(fixture.StudentScope); Assert.IsType( await studentController.Book( selfSession.Id, CancellationToken.None)); var student = await fixture.Db.Students.AsNoTracking().SingleAsync(); var service = new TimetableDataService(fixture.Db); var timetable = await service.BuildAsync( TimetableResourceType.Class, student.AdministrativeClassId, fixture.Term.Id, null, false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name), CancellationToken.None); Assert.NotNull(timetable); Assert.Equal(2, timetable.ExperimentEntries.Count); Assert.Contains( timetable.ExperimentEntries, x => x.ExperimentArrangementMode == ExperimentArrangementMode.Centralized); Assert.Contains( timetable.ExperimentEntries, x => x.Id == selfSession.Id && x.ExperimentArrangementMode == ExperimentArrangementMode.SelfScheduled); Assert.All( timetable.ExperimentEntries, x => { Assert.True(x.IsExperiment); Assert.Equal(fixture.Term.StartDate, x.ExperimentDate); Assert.Equal(1, x.StartWeek); }); var publicClassTimetable = await service.BuildAsync( TimetableResourceType.Class, student.AdministrativeClassId, fixture.Term.Id, null, false, null, null, CancellationToken.None); Assert.NotNull(publicClassTimetable); Assert.Empty(publicClassTimetable.ExperimentEntries); var booking = await fixture.Db.ExperimentBookings.SingleAsync(); Assert.IsType( await studentController.CancelBooking( booking.Id, CancellationToken.None)); timetable = await service.BuildAsync( TimetableResourceType.Class, student.AdministrativeClassId, fixture.Term.Id, null, false, student.Id, new TimetableStudentDto(student.StudentNumber, student.Name), CancellationToken.None); Assert.NotNull(timetable); Assert.Single(timetable.ExperimentEntries); Assert.Equal( ExperimentArrangementMode.Centralized, timetable.ExperimentEntries[0].ExperimentArrangementMode); } private sealed class ExperimentFixture : IAsyncDisposable { private ExperimentFixture( SqliteConnection connection, AppDbContext db, AcademicTerm term, TeachingTask task, Classroom classroom, Classroom secondClassroom, ICurrentUserDataScope managerScope, ICurrentUserDataScope studentScope) { Connection = connection; Db = db; Term = term; Task = task; Classroom = classroom; SecondClassroom = secondClassroom; ManagerScope = managerScope; StudentScope = studentScope; } private SqliteConnection Connection { get; } public AppDbContext Db { get; } public AcademicTerm Term { get; } public TeachingTask Task { get; } public Classroom Classroom { get; } public Classroom SecondClassroom { get; } public ICurrentUserDataScope ManagerScope { get; } public ICurrentUserDataScope StudentScope { get; } public static async Task CreateAsync() { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var options = new DbContextOptionsBuilder() .UseSqlite(connection) .Options; var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); var manager = User("manager", "学院实验管理员"); var studentUser = User("student", "实验学生"); var campus = new Campus { Code = "MAIN", Name = "主校区" }; var building = new Building { Code = "LAB", Name = "实验中心", CampusId = campus.Id }; var classroom = new Classroom { Code = "LAB101", Name = "实验室 101", BuildingId = building.Id, Capacity = 40 }; var secondClassroom = new Classroom { Code = "LAB102", Name = "实验室 102", BuildingId = building.Id, Capacity = 40 }; var college = new College { Code = "CS", Name = "计算机学院" }; manager.CollegeId = college.Id; var major = new Major { Code = "CS", Name = "计算机科学与技术", CollegeId = college.Id, DegreeType = "工学" }; var administrativeClass = new AdministrativeClass { Code = "CS2099", Name = "计科 2099", MajorId = major.Id, Grade = 2099 }; var student = new Student { StudentNumber = "20990001", Name = "实验学生", AdministrativeClassId = administrativeClass.Id, EnrollmentYear = 2099, EnrollmentDate = new DateOnly(2099, 9, 1), UserId = studentUser.Id }; var teacher = new Teacher { TeacherNumber = "T2099", Name = "实验教师", CollegeId = college.Id }; 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 { Code = "CSLAB", Name = "系统实验", CollegeId = college.Id, Credits = 2, TotalHours = 32, LectureHours = 16, PracticeHours = 16, Nature = CourseNature.Practice, AssessmentMethod = AssessmentMethod.Assessment }; var task = new TeachingTask { TaskNumber = "2099-1-CSLAB-01", Name = "系统实验教学班", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40, Status = TeachingTaskStatus.Published, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ], Classes = [ new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id } ] }; var secondTask = new TeachingTask { TaskNumber = "2099-1-CSLAB-02", Name = "系统实验教学班 02", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 40, Status = TeachingTaskStatus.Published }; db.AddRange( manager, studentUser, campus, building, classroom, secondClassroom, college, major, administrativeClass, student, teacher, term, course, task, secondTask); for (var period = 1; period <= 12; period++) { db.ScheduleTimeSlots.Add(new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = period, Name = $"第 {period} 节", StartsAt = new TimeOnly(8, 0).AddMinutes((period - 1) * 50), EndsAt = new TimeOnly(8, 45).AddMinutes((period - 1) * 50) }); } await db.SaveChangesAsync(); return new ExperimentFixture( connection, db, term, task, classroom, secondClassroom, Scope( manager, SystemRoles.CollegeAdmin, DataScope.College), Scope(studentUser, SystemRoles.Student, DataScope.Self)); } public ExperimentsController Controller( ICurrentUserDataScope currentScope) => new( Db, currentScope, new ClassroomReservationAvailabilityService(Db)); public ExperimentProjectRequest ProjectRequest( ExperimentArrangementMode mode) => new( Task.Id, mode == ExperimentArrangementMode.Centralized ? "LAB-C" : "LAB-S", mode == ExperimentArrangementMode.Centralized ? "集中上机实验" : "自主上机实验", mode, "完成规定实验项目。", "携带校园卡。", Term.StartDate, Term.StartDate.AddDays(14)); public ExperimentProjectBatchRequest BatchProjectRequest( ExperimentArrangementMode mode) => new( Db.TeachingTasks .OrderBy(x => x.TaskNumber) .Select(x => x.Id) .ToList(), "LAB-BATCH", "公共实验任务", mode, "多个教学任务共用的实验内容。", "携带校园卡。", Term.StartDate, Term.StartDate.AddDays(14)); public ExperimentSessionRequest SessionRequest( int startPeriod, int periodCount, int capacity = 1) => new( Classroom.Id, Term.StartDate, startPeriod, periodCount, capacity, null); 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; } }