using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.Collections; using System.Text.Encodings.Web; using System.Text.Json; namespace Jiaowu.Api.Tests; public sealed class ScheduleSettingsControllerTests { [Fact] public async Task Batch_constraints_assign_same_classroom_scope_to_all_tasks() { 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 campus = new Campus { Code = "MAIN", Name = "主校区" }; var building = new Building { Code = "J1", Name = "第一教学楼", Campus = campus }; var classroom = new Classroom { Code = "J1-201", Name = "J1-201", Building = building, Capacity = 80 }; var college = new College { Code = "CS", Name = "计算机学院" }; var course = new Course { Code = "CS101", Name = "程序设计基础", College = college, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16 }; var term = new AcademicTerm { Code = "2026-F", Name = "2026 秋季", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 7), EndDate = new DateOnly(2027, 1, 17) }; var firstTask = NewTask("TASK-01", course, term); var secondTask = NewTask("TASK-02", course, term); db.AddRange( campus, building, classroom, college, course, term, firstTask, secondTask); await db.SaveChangesAsync(); var controller = new ScheduleSettingsController(db, NoOpAppCache.Instance); var result = await controller.SaveConstraintsBatch( new TeachingTaskScheduleConstraintBatchRequest( term.Id, [firstTask.Id, secondTask.Id], null, null, null, true, campus.Id, building.Id, [classroom.Id], false, null, null), CancellationToken.None); Assert.IsType(result); db.ChangeTracker.Clear(); var constraints = await db.TeachingTaskScheduleConstraints .Include(item => item.AllowedClassrooms) .OrderBy(item => item.TeachingTaskId) .ToListAsync(); Assert.Equal(2, constraints.Count); Assert.All(constraints, constraint => { Assert.True(constraint.RequiresClassroom); Assert.Equal(campus.Id, constraint.RequiredCampusId); Assert.Equal(building.Id, constraint.RequiredBuildingId); Assert.Equal( classroom.Id, Assert.Single(constraint.AllowedClassrooms).ClassroomId); }); } [Fact] public async Task Get_constraints_materializes_teachers_and_allowed_classrooms() { 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 course = new Course { Code = "CS101", Name = "程序设计基础", College = college, Credits = 4, TotalHours = 64, LectureHours = 48, PracticeHours = 16 }; var term = new AcademicTerm { Code = "2026-F", Name = "2026 秋季", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 7), EndDate = new DateOnly(2027, 1, 17) }; var teacher = new Teacher { TeacherNumber = "T001", Name = "测试教师", College = college }; var classroom = new Classroom { Code = "J1-201", Name = "J1-201", Capacity = 80, Building = new Building { Code = "J1", Name = "第一教学楼", Campus = new Campus { Code = "MAIN", Name = "主校区" } } }; var task = NewTask("TASK-01", course, term); task.Teachers.Add(new TeachingTaskTeacher { Teacher = teacher, IsPrimary = true }); var constraint = new TeachingTaskScheduleConstraint { TeachingTask = task, AllowedDayOfWeeks = "1,3,5", EarliestPeriod = 1, LatestPeriod = 8, AllowedClassrooms = [ new TeachingTaskAllowedClassroom { Classroom = classroom } ] }; db.AddRange(college, course, term, teacher, classroom, task, constraint); await db.SaveChangesAsync(); var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance) .GetConstraints(term.Id, CancellationToken.None); var ok = Assert.IsType(result); var payload = Assert.IsAssignableFrom(ok.Value); Assert.Single(payload.Cast()); var json = JsonSerializer.Serialize( ok.Value, new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); Assert.Contains("测试教师", json); Assert.Contains(classroom.Id.ToString(), json); } [Fact] public async Task MySql_schedule_settings_materialize_time_slots_and_constraints() { var connectionString = Environment.GetEnvironmentVariable("JIAOWU_TEST_MYSQL_CONNECTION"); if (string.IsNullOrWhiteSpace(connectionString)) return; var previousConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings__MySql"); AppDbContext db; try { Environment.SetEnvironmentVariable( "ConnectionStrings__MySql", connectionString); db = new AppDbContextFactory().CreateDbContext([]); } finally { Environment.SetEnvironmentVariable( "ConnectionStrings__MySql", previousConnectionString); } await using var ownedDb = db; var term = await db.AcademicTerms.FirstAsync(); var course = await db.Courses.FirstAsync(); var teacher = await db.Teachers.FirstAsync( item => item.CollegeId == course.CollegeId); var classroom = await db.Classrooms.FirstAsync(); await using var transaction = await db.Database.BeginTransactionAsync(); var periodNumber = await db.ScheduleTimeSlots .Where(item => item.AcademicTermId == term.Id) .Select(item => (int?)item.PeriodNumber) .MaxAsync() ?? 0; var task = new TeachingTask { TaskNumber = $"MYSQL-{Guid.NewGuid():N}"[..22], Name = "MySQL 排课约束回归", AcademicTermId = term.Id, CourseId = course.Id, Capacity = 60, WeeklyHours = 2, StartWeek = 1, EndWeek = 16, Status = TeachingTaskStatus.Published, Teachers = [ new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true } ] }; db.Add(task); db.Add(new ScheduleTimeSlot { AcademicTermId = term.Id, PeriodNumber = periodNumber + 1, Name = "MySQL 回归节次", StartsAt = new TimeOnly(8, 0), EndsAt = new TimeOnly(8, 45) }); db.Add(new TeachingTaskScheduleConstraint { TeachingTask = task, AllowedDayOfWeeks = "1,3,5", EarliestPeriod = 1, LatestPeriod = 8, AllowedClassrooms = [ new TeachingTaskAllowedClassroom { ClassroomId = classroom.Id } ] }); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance) .GetConstraints(term.Id, CancellationToken.None); var ok = Assert.IsType(result); var payload = Assert.IsAssignableFrom(ok.Value); Assert.Contains(payload.Cast(), item => JsonSerializer.Serialize(item).Contains(task.TaskNumber)); var timeSlotsResult = await new ScheduleSettingsController( db, NoOpAppCache.Instance) .GetTimeSlots(term.Id, CancellationToken.None); var timeSlotsOk = Assert.IsType(timeSlotsResult); var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value); Assert.Contains("08:00", timeSlotsJson); var examTimeSlotsResult = await new ExamsController( db, null!, null!, NoOpAppCache.Instance) .GetTimeSlotsForTerm(term.Id, CancellationToken.None); var examTimeSlotsOk = Assert.IsType(examTimeSlotsResult); Assert.Contains( "08:00", JsonSerializer.Serialize(examTimeSlotsOk.Value)); var makeupTimeSlotsResult = await new MakeupExamsController( db, null!, null!, null!, null!) .GetTimeSlotsForTerm(term.Id, CancellationToken.None); var makeupTimeSlotsOk = Assert.IsType(makeupTimeSlotsResult); Assert.Contains( "08:00", JsonSerializer.Serialize(makeupTimeSlotsOk.Value)); await transaction.RollbackAsync(); } private static TeachingTask NewTask( string taskNumber, Course course, AcademicTerm term) => new() { TaskNumber = taskNumber, Name = $"{course.Name}教学班", Course = course, AcademicTerm = term, Capacity = 60, WeeklyHours = 4, StartWeek = 1, EndWeek = 16, Status = TeachingTaskStatus.Published }; }